Skip to content

Federated trust model design

Synapse is local-first and operator-managed: one hub, one operator, loopback by default, with optional shared tokens, file permissions, per-agent identity, signed events, mutual TLS, and release receipts layered on top. This document defines how those single-domain primitives compose into a federated trust model — multiple independently operated Synapse domains that let scoped agents and evidence cross an organisational boundary without surrendering local-first control.

The opt-in policy, persistence, lifecycle, exchange, frame-authorisation, and guarded multi-hub primitives described below are implemented and tested. This page also preserves the residual boundary: Synapse does not automate the trust decision, operate a certificate authority, certify an external federation, or turn these primitives into a managed multi-organisation service.

Runtime status

These single-domain primitives are implemented and are the building blocks a federation layer would compose — it would add no new trust root of its own:

  • Identity and ACLs — trust-on-first-use or operator-bundle identity binding, shadow evaluation, and opt-in deny-by-default runtime ACL enforcement (synapse identity, synapse acl, --identity-trust, --require-acl). See identity and ACL.
  • Signed eventsEventSignatureKey / EventSignatureTrustBundle verify Ed25519-signed coordination frames with sender, project, expiry, replay, and revocation checks. See signed events and mTLS.
  • Mutual TLS peersMTLSPeerTrustBundle verifies trusted-peer certificate pins, project scope, signing-key scope, and revocation for multi-host hubs.
  • Release receipts — bounded, evidence-bearing receipts with epistemic status, carried on release_granted and recorded on the board.
  • Signed capability cards (design) — a planned card-signing profile, see signed capability cards.

The deny-by-default policy bundle has shipped in core/federation.py: a FederationPeer records, per remote domain, the local namespaces it may address, the accepted certificate pins and event-signing key ids, the bounded local scope (ScopeGrant verb/namespace pairs) its subjects map to, and an expiry plus revocation; FederationBundle.authorise returns a deny-by-default decision (unknown domain, revoked, expired, namespace not granted, key not accepted, pin not accepted, in that order), and compose_cross_domain joins it with the external mutual TLS, signature, and ACL results so a frame any layer rejects is rejected. It owns no crypto and adds no trust root.

expires_at, provenance imported_at, and rotation lifetimes are POSIX wall-clock epoch seconds. Lifecycle and serving decisions compare them with time.time()-equivalent values, never with process-relative monotonic time.

That policy is now composed into the live runtime, on both surfaces, deny-closed and opt-in:

  • Hub-to-hub — the multi-hub event-log pull and cross-hub claim forwarding gate one hub serving or forwarding to another against the peering and the live certificate pin (MultiHubServingPolicy, authorise_multihub_pull).
  • Agent frames — a hub started with synapse hub --federation-store FILE composes the imported peerings into its per-frame authorisation: a frame whose verified Ed25519 signing key and live certificate pin resolve to one peered domain (resolve_domain) is authorised against that peering's bounded scope (scope_authorises), composed with mutual TLS, the event signature, and the mapped scope, instead of the local ACL. A frame resolving to no peer stays local only when its signing key is unpeered; a peered key that cannot be bound to a single peering is denied. Federation only binds authority on a hub that also runs --require-message-auth; without it a cross-domain frame is refused, since its signing key is not verified. The hub therefore refuses to start when the store's peerings grant cross-domain scope but --require-message-auth is not set — the configuration claims an authorisation it can never perform. Two configurations remain valid without it: a store whose peerings grant no enforceable scope (revoked, expired, credential-less, or scope-less — observe-only by construction, started with a warning), and a scope-granting store started with --federation-observe-only, the operator's declared intent to load the peerings for diagnostics and deny-closed refusal only. Declaring --federation-observe-only alongside --require-message-auth is a contradiction and is likewise refused. With no store the live path is unchanged.

A frame that carries a signing key over a pinned connection yet resolves to no peering is disposed of by whether the key is peered. A key no peering enrols is an ordinary local frame and is handled locally; because a peering whose signing-key enrolment is stale fails the same way, the hub logs a warning when the certificate pin alone is enrolled (diagnose_unresolved_domain) so a wrong key id does not go silent, and stays quiet when neither credential is enrolled. A peered key that fails to resolve is denied outright (peer_domain_unresolved), exactly as a peered key on an unpinnable connection is (peer_certificate_unavailable): the frame claims cross-domain authority that binds to the verified key-and-pin pair, so a stale or foreign certificate, credentials split across peerings, or an ambiguous pair two peerings both claim must never downgrade it to local processing. The operator log names the diagnosis; the wire reply carries only the reason.

Bundle transport over the network has shipped: a hub serves its own operator-authored bundle material (synapse hub --federation-offer), and a peer operator pulls it (synapse federation fetch) instead of moving a file by hand. What stays out-of-band — deliberately and permanently — is the trust decision: both operators compare the bundle fingerprint over an independent channel (synapse federation offer prints the same block the fetch displays) and only then import explicitly (synapse federation import --confirmed-by). The hub does not auto-discover or negotiate trust, and there is no trust-on-first-use. This document is the boundary specification for that ceremony.

Trust domains

A trust domain is one operator's Synapse deployment: a hub (or a set of mutually-trusting mTLS hubs), the project namespaces it owns, the agent identities it issues, and the signing keys and certificate pins it manages. A domain is the unit of federation and the unit of revocation. Every federated statement is scoped to a named domain so that a claim, signature, or receipt is always attributable to exactly one issuing operator.

A domain is identified by a stable domain id and the set of project namespaces it is authoritative for. A domain is authoritative only for its own namespaces: a remote domain may assert facts about its agents and tasks, but it is never authoritative for another domain's namespaces, and a verifier always resolves authority by the issuing domain, never by the asserted content.

Cross-domain peer federation

Federation extends the existing single-host MTLSPeerTrustBundle and EventSignatureTrustBundle from "trusted peer hosts" to "trusted peer domains". A federation bundle records, per remote domain:

  • the remote domain id and the project namespaces it is allowed to address locally (deny-by-default; a remote domain addresses nothing until granted);
  • accepted certificate pins for the remote hub(s);
  • accepted Ed25519 event-signing key ids for the remote domain;
  • a local-side scope mapping that translates remote subjects into locally meaningful, bounded permissions;
  • expiry and revocation state for the whole peering.

Verification composes the primitives that already exist: a cross-domain frame must satisfy mutual TLS peer verification (pin + peer scope) and event signature verification (key id + sender + project + replay + expiry + revocation) and the local ACL for the mapped scope. Federation never weakens any single check; it only refuses to widen one. A frame that any layer rejects is rejected.

Trust-bundle exchange and provenance

Federation needs verification keys and certificate pins to move between domains, but Synapse is not a certificate authority and must not become one. The trust decision is therefore out-of-band and operator-confirmed: operators confirm domain bundles through their existing trusted channel (a call, a ticket, a key-signing exchange) and import them explicitly. synapse federation import (shipped) records the bundle with its provenance — who provided it, when, and which operator confirmed it (a required --confirmed-by) — so every federated trust relationship is auditable back to a human decision, not auto-discovered from the network; synapse federation list shows the imported peerings with each peering's age, expiry distance, and rotation state. A peering in an add-new-before-retire grace window renders as rotation=overlap, an expired bundle renders as expired, and --max-age flags active peerings whose ceremony has gone stale, exiting 1 for scheduled checks. synapse federation revoke retires one while keeping its audit record. There is no automatic trust-on-first-use and no network-driven trust root.

synapse federation rotate refreshes an operator-authored offer bundle with a new wall-clock expiry and add-new-before-retire signing keys or certificate pins; it preserves a backup and changes the fingerprint, so peers repeat the same out-of-band comparison before re-importing it.

The bundle bytes may move over the wire (shipped): a hub started with --federation-offer FILE answers a peer operator's synapse federation fetch with its own published bundle material, and both sides print an identical fingerprint block — the domain id, signing key ids, certificate pins, and a whole-bundle SHA-256 — computed by the same code (synapse federation offer on the offering side). The fetched file is untrusted until the operators compare the bundle fingerprint over an independent channel, the SSH-known-hosts ceremony; the fetch never imports, and the import command records the fetch URI as the peering's provenance source. A fingerprint over the whole canonical bundle means an in-path alteration of any policy content — an added namespace or scope grant as much as a swapped key — changes the value the operators read to each other.

The ceremony, end to end

Two operators, two domains. The atelier.example operator validates their own bundle material and starts serving it; every output below is captured from a real run of the shipped commands.

atelier$ synapse federation offer ./atelier.json
domain:             atelier.example
signing key ids:    atelier-signing-2026
certificate pins:   sha256:9b1f63a2c8d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0
namespaces:         atelier/shared-docs
scope grants:       1
expires:            2026-10-03T04:00:00Z
bundle fingerprint: sha256:eb240a1ea0be0ba5d96641ab8905905fa2fe09a41dc50cecf746cf19776ad47d

serve it:  synapse hub --federation-offer ./atelier.json
then read the bundle fingerprint to the peer operator out-of-band; they
compare it against their `synapse federation fetch` output before importing.

The brioni.example operator fetches the offered material from the live hub and sees the identical block — same renderer, same whole-bundle fingerprint:

brioni$ synapse federation fetch ws://atelier-hub:8951 --out ./fetched-atelier.json
domain:             atelier.example
signing key ids:    atelier-signing-2026
certificate pins:   sha256:9b1f63a2c8d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0
namespaces:         atelier/shared-docs
scope grants:       1
expires:            2026-10-03T04:00:00Z
bundle fingerprint: sha256:eb240a1ea0be0ba5d96641ab8905905fa2fe09a41dc50cecf746cf19776ad47d

wrote the offered bundle to fetched-atelier.json — NOT imported.
compare the bundle fingerprint with the peer operator out-of-band (their
`synapse federation offer` prints the same block), then import explicitly:
  synapse federation import fetched-atelier.json --confirmed-by <operator> --source ws://atelier-hub:8951

For a wss:// peer whose certificate is private-CA or self-signed, the fetching operator can pin the live certificate explicitly:

synapse federation fetch wss://atelier-hub:8951 \
  --pin sha256:9b1f63a2c8d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0 \
  --out ./fetched-atelier.json

That pin is checked against the certificate presented on the TLS socket before the offered bundle is decoded. It is still only transport verification: the operator must compare the bundle fingerprint out-of-band and import explicitly. If the URI points at a TLS-terminating reverse proxy, the pin is the proxy's certificate, not the hub certificate. That is a different trust boundary from direct hub mTLS. Use direct WSS/mTLS, TCP/TLS passthrough, or a tailnet path when the peering depends on the hub certificate pin or hub-side client certificate verification; synapse doctor --federation-path PEER=MODE makes that declaration visible in health checks and fails the plain terminating-proxy mode for certificate-pinned federation.

The operators now read eb240a1e… to each other over an independent channel — a call, a signed ticket, a key-signing meetup. Only when the values match does the fetching operator import, and the import records who confirmed it and where the bytes came from. With a --max-age lifetime policy the import also grades the bundle's expiry on the spot:

brioni$ synapse federation import ./fetched-atelier.json --confirmed-by brioni-ops \
    --source ws://atelier-hub:8951 --max-age 90
warning: bundle expiry is 92 days out, beyond --max-age 90
imported peering with domain 'atelier.example' (1 namespaces, 1 keys, 1 pins), confirmed by brioni-ops

brioni$ synapse federation list --max-age 90
1 peer domain(s):
  atelier.example [active] namespaces=atelier/shared-docs keys=1 pins=1 scope=1 expires=2026-10-03T04:00:00Z (in 90.0d) rotation=steady — confirmed by brioni-ops from ws://atelier-hub:8951, imported 0 day(s) ago

The mirror-image run — brioni offering, atelier fetching — completes the pair. Nothing in the path decided trust: the hub only moved bytes, both fingerprint blocks came from the same rendering code, and the peering exists because two humans compared a hash and said so on the record.

Scoped cross-domain authorisation

A remote domain's agents never inherit local permissions. The federation bundle maps a remote subject to a bounded local scope: specific verbs (for example, read board, post chat to a shared channel, submit a receipt) over specific project namespaces, deny-by-default. Cross-domain authorisation reuses the local ACL engine — the mapped scope is evaluated exactly like a local subject's — so there is one authorisation path, not a parallel federated one. Private-channel membership and at-rest/payload encryption boundaries are unchanged: a remote subject is a member of nothing until a local membership grant says so.

Evidence and receipt portability

Release receipts are the natural portable evidence across domains because they already carry bounded, self-describing fields and an epistemic status. A federated verifier can read a remote receipt and check what is verifiable (signature, key id, declared evidence, freshness) versus what is merely asserted (the producer's confidence). Crucially, a receipt remains advisory evidence across a domain boundary exactly as it is locally: it documents claimed checks, it does not certify sufficiency, and a remote receipt never auto-approves a local merge or release. Portability widens who can read the evidence, not what the evidence proves.

Revocation and incident propagation

Revocation is domain-scoped and explicit. A domain revokes its own keys, pins, or peerings; federated peers learn of revocation through the same out-of-band exchange that established the peering. A bundle re-import propagates new key and pin material, and a revoked imported peering is refused on the next runtime authorisation check while the audit record remains listed. Because every federated statement is attributable to one issuing domain, incident response can scope blast radius to a single domain and its explicit peerings rather than an implicit transitive web.

Relationship to other designs

This model is the composition layer above shipped and staged security profiles. It depends on identity and ACL for the local authorisation path, signed events and mTLS for cross-domain authentication and integrity, signed capability cards for portable capability provenance, and the agent trust graph for evidence aggregation. It does not replace any of them, and it adds no trust root they do not already define.

Boundaries

The deny-by-default policy and its composition into the live hub-to-hub and agent-frame paths have shipped (see Runtime status), and so has the bundle-exchange transport (offer/fetch); the trust decision itself is out-of-band by design and stays so. These boundaries hold regardless and are never relaxed by what ships:

  • It is not a certificate authority or PKI: it distributes no keys, issues no certificates, and performs no automatic trust discovery. Trust roots are operator-confirmed and out-of-band.
  • It does not authorise untrusted organisations. Federation is deny-by-default between explicitly peered, operator-managed domains only.
  • It does not replace per-agent identity, signed events, mutual TLS, ACLs, or receipts — it composes them, and it never weakens a check any of them perform.
  • It does not make a release receipt anything more than advisory evidence across a domain boundary, and a remote receipt never auto-approves a local action.
  • It does not change the local-first default: a domain that imports no federation bundle behaves exactly as a single local deployment, and federation adds no network-reachable trust surface unless an operator explicitly enables and scopes it.