Federation
Authorized event exchange between two Titen deployments, with filters in both directions and no last-write-wins.
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.
The default pull exchanges metadata-only events. An explicit include_memory: true pull
can add one complete organization-visible claim and its evidence graph. Credentials and
local authority 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
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.
Peer/filter management and pull/push require both their federation scope and an active
organization owner or admin role. A reader may list peers, filters and log entries.
The wildcard root bypass exists only for bootstrap and recovery. Every peer also remains
bound to the principal that registered it.
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.
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"}'
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 unverified → policy_approved
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, which keeps the scan bounded. Widening
a filter therefore does not retroactively release history: the peer sees the change from
that point forward.
Set include_memory: true to opt into canonical transfer. It additionally requires
export:read and an explicit claim filter, and returns at most one eligible
claim.materialized event so the signed push remains below the body limit. The memory
object carries one active or disputed organization-visible direct claim, its project
identity, observations and evidence relations. A hidden or incomplete graph is omitted.
POST /v1/federation/push accepts a batch from a remote deployment. It needs two
independent proofs.
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. The
message names 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. Event-only malformed or filtered entries report their own outcome. A canonical provenance, trust or source-binding violation fails the batch atomically, so no partial graph survives:
| Result | Meaning |
|---|---|
success |
inserted, and logged as received |
conflict |
that event id already exists locally; nothing is overwritten |
replayed |
the identical canonical graph was already imported |
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?”.
Canonical import boundary
A canonical push additionally requires import:write; creating a missing project reference
also requires projects:create. The destination accepts only a complete same-subject and
same-project graph within the importing key’s trust ceiling. Remote policy_approved trust
is rejected: approval is a local governance decision.
The first accepted canonical graph binds the peer’s source_org_id. A later or concurrent
push claiming another source organization returns 409 and rolls back its complete batch.
Imported observations and claims receive local ids while federated_records preserves
remote ids, actor, time and payload hash. Reusing an event identity with changed content or
new canonical records is 409; an identical graph is replayed without duplication.
The signature authenticates the configured peer transport, not a global organization registry. Run federation between deployments you control, protect the network path, and read the log. If one deployment can serve several teams, use workspaces and memberships instead.