# Federation

> Authorized event exchange between two Titen deployments, with filters in both directions and no last-write-wins.

Section: Collaboration · Source: https://titen.dev/docs/federation
Derived from: https://github.com/RamaAditya49/titen/blob/main/docs/reference/api.md

---
## When you need it

Federation is not a scaling mechanism. One deployment handles an organization, and two cost
you a synchronization problem. Reach for it only when a boundary you cannot move forces the
split: separate ownership, a data-residency region, or a network that will never let one
side reach the other.

What crosses the wire is the event log, not memory. Events carry ids, kinds, actors and
bounded scope metadata (`subject_id`, `kind`, `trust`, `status`), never observation or
claim content. Credentials are never replicated.

<div class="rules">

<p>Filter by source policy before transmission.</p>
<p>Preserve conflicts. An id that already exists locally is never overwritten.</p>
<p>A scope can stop sharing without corrupting local history.</p>
<p>No CRDT and no consensus algorithm until real offline and concurrent cases are measured.</p>

</div>

## Registering a peer

<figure class="code"><figcaption>peer.sh<b>bash</b></figcaption>

```sh
curl -X POST http://127.0.0.1:8787/v1/federation/peers \
  -H "authorization: Bearer $TITEN_API_KEY" -H 'content-type: application/json' \
  -d '{"name":"eu-node","endpoint":"https://eu.example.com/v1/federation/push",
       "shared_secret":"a-long-enough-shared-secret","direction":"bidirectional"}'
```

</figure>

```json
{ "data": { "peer_id": "fpeer_ea73516ceccf4454964273750e70af14", "name": "eu-node",
    "endpoint": "https://eu.example.com/v1/federation/push",
    "direction": "bidirectional", "status": "active",
    "created_at": "2026-07-30T10:49:13.337Z" } }
```

`shared_secret` must be at least 16 characters. `direction` is `push`, `pull` or
`bidirectional`, and it is enforced per operation: a `push`-only peer refuses pull with
`403 Peer is configured for push only.`, and the mirror case holds. One endpoint per
organization; a duplicate returns `409`.

`POST /v1/federation/peers/:id/suspend` is the stop switch behind "a scope can stop
sharing": a suspended peer's pull and push both return `403 Peer is not active.` before any
event is read or written. The refusal happens ahead of the log, so it shows up as an
absence of new rows rather than a rejection entry.

## Filters bound both directions

A filter says what may leave and what may be accepted, per resource type.

```sh
curl -X POST http://127.0.0.1:8787/v1/federation/peers/$PEER/filters \
  -H "authorization: Bearer $TITEN_API_KEY" -H 'content-type: application/json' \
  -d '{"resource_type":"claim","include_kinds":"claim.materialized","min_trust":"verified"}'
```

<div class="kv">
<strong>FILTER FIELDS</strong>

<p><code>resource_type</code> <code>observation</code>, <code>claim</code> or <code>event</code>, required</p>
<p><code>include_kinds</code> comma-separated event kinds; anything else fails the filter</p>
<p><code>exclude_subjects</code> comma-separated <code>subject_id</code> values that never cross</p>
<p><code>min_trust</code> minimum <code>trust</code> in the event payload, ranked <code>unverified</code> → <code>policy_approved</code></p>
</div>

<div class="callout callout--alarm">
<strong>ONE FILTER IS A DENY-BY-DEFAULT LIST</strong>

With no filters at all, everything passes. The moment you register one filter, an event
whose `resource_type` has no filter row is **rejected**. Adding a `claim` filter therefore
stops observation and event traffic. If you want claims narrowed and observations
still flowing, register a filter row for each resource type you intend to allow.
</div>

## Pull, push, and the cursor

`POST /v1/federation/pull` returns local events after the peer's stored cursor, capped at
200 per call, filtered, and advances the cursor.

```json
{ "data": { "events": [ { "id": "evt_4ff141c765f045d6824c7e2b66732eb5",
        "kind": "claim.materialized",
        "actor_id": "agent_8df7b8699a0945488ceb3d4b104006d6",
        "resource_type": "claim",
        "resource_id": "claim_f3963d7b876143f5bfc8230db2757067",
        "payload": { "subject_id": "user_rama", "kind": "procedural",
          "status": "active", "trust": "verified" },
        "created_at": "2026-07-30T10:38:59.741Z" } ],
    "cursor": "evt_0b6e9d6a45ae4e72b46eebe6e692320d" } }
```

The returned `cursor` is the last event **scanned**, not the last event sent. Notice that
the id above appears nowhere in `events`. Filtered-out events are permanently behind the
cursor and will not be re-offered on the next pull, which keeps the scan bounded. Widening
a filter therefore does not retroactively release history: the peer sees the change from
that point forward.

`POST /v1/federation/push` accepts a batch from a remote deployment. It needs two
independent proofs.

<figure class="code"><figcaption>push.ts<b>typescript</b></figcaption>

```ts
const body = JSON.stringify({ peer_id: peerId, events });
const key = await crypto.subtle.importKey(
  'raw', new TextEncoder().encode(sharedSecret),
  { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'],
);
const mac = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(body));
const hex = [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, '0')).join('');

await fetch('http://127.0.0.1:8787/v1/federation/push', {
  method: 'POST',
  headers: {
    authorization: `Bearer ${process.env.TITEN_API_KEY}`,
    'content-type': 'application/json',
    'x-titen-peer-signature': `sha256=${hex}`,
  },
  body,
});
```

</figure>

The API key proves the caller may use the endpoint. The signature over the raw body proves
the batch came from the peer that was registered. Without the second one, any authorized
caller inside the organization could inject records attributed to a remote deployment. A
missing header and a wrong signature are both `403 FORBIDDEN` before any event is read. The
message names which of the two happened, `Peer signature header is required.` or
`Peer signature is invalid.`:

```json
{ "error": { "code": "FORBIDDEN", "message": "Peer signature header is required." } }
```

The comparison is constant-time. Each event in the batch reports its own outcome, so a bad
event does not fail the batch:

<div class="table-wrap">

| Result | Meaning |
| --- | --- |
| `success` | inserted, and logged as `received` |
| `conflict` | that event id already exists locally; nothing is overwritten |
| `rejected` | failed the filters, or the event object had no usable shape |

</div>

## The log

`GET /v1/federation/log` requires a `peer_id` query parameter and returns newest first.
`limit` defaults to 50 and is capped at 200.

```json
{ "data": { "entries": [ { "id": "flog_7675e7c5751a492092cd7c5a3c6fb23f",
        "direction": "sent", "resource_type": "claim",
        "resource_id": "evt_75cc5557bff34da8a9fd298c6231ac63",
        "status": "success", "detail": null,
        "created_at": "2026-07-30T10:49:22.138Z" } ] } }
```

Both directions land here: `sent` rows from pull, `received` rows from push, including the
`rejected` ones with the reason in `detail`. This is the record that answers "did that
subject's events ever leave this deployment?".

## What is not settled

Federation ships as eight working routes, and the protocol around them is not finished.
The threat model registers forged or replayed federation events as TM-16, with verification
pending a separate protocol review, and does not approve the protocol. Two gaps in the
current code:

<div class="table-wrap">

| Gap | Today |
| --- | --- |
| Source timestamps | a received event keeps its id, kind and `actor_id`, but `created_at` is stamped with the local receive time, so the source event's own time is not preserved |
| Replay window | the signature is verified per request with no timestamp or nonce; a replayed batch is idempotent only because duplicate event ids return `conflict` |

</div>

Run it between deployments you control, on a network you trust, and read the log. If you
need one deployment serving several teams, use
[workspaces and memberships](/docs/identity) instead.

## Next

<div class="cards">

<a href="/docs/webhooks"><strong>Webhooks &amp; events</strong><span>The local event stream federation reads from, and its signing contract.</span></a>
<a href="/docs/backup-export"><strong>Backup &amp; export</strong><span>NDJSON export and import, the other way records move between deployments.</span></a>
<a href="/docs/identity"><strong>Identity &amp; visibility</strong><span>Scopes and memberships, for splitting one deployment instead of two.</span></a>

</div>