Titentiten.devꦠꦶꦠꦺꦤ꧀
v0.1.1 · npmGitHub
DocsCollaborationFederation

Federation

Authorized event exchange between two Titen deployments, for when one deployment cannot satisfy an ownership, region or network boundary — with filters on both directions and no last-write-wins.

push · pull · bidirectional200 events per pullHMAC-SHA256 peer signature

When you need it, and when you do not

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 — and never observation or claim content. Credentials are never replicated.

Filter by source policy before transmission.

Preserve conflicts. An id that already exists locally is never overwritten.

A scope can stop sharing without corrupting local history.

No CRDT and no consensus algorithm until real offline and concurrent cases are measured.

Registering a peer

peer.shbash
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"}'
{ "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, and it is the mechanism 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.

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"}'
FILTER FIELDS

resource_type observation, claim or event — required

include_kinds comma-separated event kinds; anything else fails the filter

exclude_subjects comma-separated subject_id values that never cross

min_trust minimum trust in the event payload, ranked unverifiedpolicy_approved

ONE FILTER IS A DENY-BY-DEFAULT LIST

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.

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.

{ "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. That keeps the scan bounded, and it means widening a filter 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.

push.tstypescript
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,
});

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, and they say which of the two happened — Peer signature header is required. or Peer signature is invalid.:

{ "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:

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

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.

{ "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 explicitly not finished. The threat model registers forged or replayed federation events as TM-16, marks its verification as pending a separate protocol review, and states that this document does not approve the protocol. Two concrete gaps in the current code:

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

Run it between deployments you control, on a network you trust, and read the log rather than assuming. If what you actually need is one deployment serving several teams, use workspaces and memberships instead — no protocol, no cursor, no secret to rotate.

Next

↑↓ navigate↵ open