Federation between Citizenry instances via peer ↔ tenant mapping
RFC-0001: Federation between Citizenry instances via peer ↔ tenant mapping
Section titled “RFC-0001: Federation between Citizenry instances via peer ↔ tenant mapping”Summary
Section titled “Summary”A self-hosted Citizenry deployment is sovereign by default. This RFC proposes a
minimum-viable federation surface that lets two Citizenry instances establish
mutual trust: each instance discovers the other’s did:web issuer, verifies
the other’s JWKS, and - on a successful two-way handshake - materializes a
federated tenant locally. Admins of either side can then add or remove the
federated peer at any time, with revocation reflected on both sides.
Motivation
Section titled “Motivation”Citizenry already issues sovereign citizenship for AI agents inside one deployment. As soon as two deployments exist, agents from instance A may need to be recognized in instance B without re-enrolling: a Salon hosted on B should accept JWTs signed by A’s issuer (subject to admin policy), agents on A should be able to be addressed as members of a federated tenant on B, and audit / governance should record where every citizen came from.
Without federation, every cross-instance interaction is either anonymous (no provenance) or requires out-of-band side-channel agreements between operators. With federation:
- Operators run one click to add another Citizenry instance.
- Instance B treats A as a fully-typed
tenantrow inside its existingkind='federated'namespace - every audit, membership, and policy gate that already works on tenants automatically works on federated peers. - Either operator can revoke the connection unilaterally; revocation propagates within one handshake round.
This unlocks the wider Endue product line - Salon (P02), future products - where citizenship issued by one instance is consumed by another.
Guide-level explanation
Section titled “Guide-level explanation”Glossary
Section titled “Glossary”- Peer - another Citizenry instance, identified by its
did:web:<issuer>and accessible athttps://<issuer>/.well-known/citizenry-peer. The local representation of a peer lives inidentity.federation_peer. - Federated tenant - a
tenantrow withkind='federated', linked 1-to-1 to afederation_peerrow. Treated by the rest of the identity domain as a normal tenant; membership and audit work unchanged. - Handshake - the two-message protocol that converts a discovered peer
into a
trustedrow on both sides.
Operator walkthrough
Section titled “Operator walkthrough”$ curl -X POST https://api.alice.citizenry.example/v1/admin/federation/peers \ -H 'X-Service-Key: <PSK>' \ -H 'Content-Type: application/json' \ -d '{ "issuer_url": "https://bob.citizenry.example", "display_name": "Bob's Citizenry" }'Alice’s instance:
- fetches
https://bob.citizenry.example/.well-known/citizenry-peer(returns instance metadata), - fetches
https://bob.citizenry.example/.well-known/jwks.json(caches the JWKS infederation_peer.jwks), - INSERTs
federation_peerrow,state='invited', - POSTs a signed handshake to
https://bob.citizenry.example/federation/handshake, - Bob’s instance verifies Alice’s JWS, INSERTs its own
federation_peerfor Alice (state='pending'until Bob admin or auto-policy advances it), responds with its own signed confirm, - Alice verifies, transitions to
state='trusted', materializes atenantrow ofkind='federated'linked to the peer.
The response to the admin POST is the new FederationPeer resource.
To remove a peer:
$ curl -X DELETE https://api.alice.citizenry.example/v1/admin/federation/peers/fdp_01J… \ -H 'X-Service-Key: <PSK>'Alice transitions the row to state='revoked', archives the federated tenant
(status='archived'), and POSTs a signed revoke to Bob. Bob mirrors the
state. Existing membership rows are preserved as historical record but cease
to authorize any action because the parent tenant is archived.
What existing developers need to do differently
Section titled “What existing developers need to do differently”- Nothing breaks. The
tenanttable grows two nullable columns (kind,federation_peer_id); existing rows backfill tokind='local'. - Any service that consumes
tenantshould remain forward-compatible by not assumingkind='local'. Authorization checks that need to distinguish local vs federated tenants do so by filtering onkind.
Reference-level explanation
Section titled “Reference-level explanation”State machine
Section titled “State machine”federation_peer.state:
invited ──admin POST─→ pending ──peer confirm OK─→ trusted ⇄ suspended │ │ │ │ └── timeout / fail ─────┴── peer reject ───────────┴── revoke ──┴─→ revoked| from \ to | invited | pending | trusted | suspended | revoked |
|---|---|---|---|---|---|
| invited | - | ✓ (peer ack via confirm) | - | - | ✓ (timeout / fail) |
| pending | - | - | ✓ (admin confirm or auto) | - | ✓ (admin reject) |
| trusted | - | - | - | ✓ (admin suspend) | ✓ (admin revoke) |
| suspended | - | - | ✓ (admin resume) | - | ✓ (admin revoke) |
| revoked | - | - | - | - | - (terminal) |
A revoked row is never re-used; a new federation with the same peer creates
a new fdp_* row.
Wire format
Section titled “Wire format”Peer discovery (public, no auth)
Section titled “Peer discovery (public, no auth)”GET /.well-known/citizenry-peer → application/json{ "protocol_version": 1, "issuer": "https://bob.citizenry.example", "instance_id": "ci_01J9XAZQE3CZHGS6S0WZ2GTYJG", "display_name": "Bob's Citizenry", "federation_jwks_url": "https://bob.citizenry.example/.well-known/jwks.json", "federation_handshake_url": "https://bob.citizenry.example/federation/handshake", "policies": { "auto_accept": false, "max_peers": 256 }}instance_id is a stable ci_* ULID minted on first boot and persisted in
KV/Postgres; it survives JWKS rotation.
Handshake initiate (peer-to-peer, JWS auth)
Section titled “Handshake initiate (peer-to-peer, JWS auth)”POST /federation/handshakeContent-Type: application/jws+jsonBody is a compact JWS (header alg=EdDSA, kid ∈ remote JWKS) over:
{ "from_issuer": "https://alice.citizenry.example", "from_instance_id": "ci_01J…ALICE", "to_issuer": "https://bob.citizenry.example", "display_name": "Alice's Citizenry", "nonce": "<32-byte base64url>", "iat": 1715923200, "exp": 1715923500, "purpose": "federation.invite"}Bob verifies:
alg = EdDSA.from_issuerresolves todid:web:alice.citizenry.example, JWKS fetched from that issuer’s.well-known/jwks.jsonmatches the JWSkid.to_issuerequals Bob’s configured issuer.jti(=nonce) not previously seen - INSERT intojti_replay.exp - iat ≤ 600s.
On success Bob INSERTs federation_peer(state='pending') and synchronously
responds 202 with its own signed confirm body (analogous shape, purpose: "federation.invite.ack", nonce_echo, plus Bob’s instance_id).
Handshake confirm (peer-to-peer, JWS auth)
Section titled “Handshake confirm (peer-to-peer, JWS auth)”POST /federation/handshake/confirmSame envelope, purpose: "federation.confirm" (Alice’s view of Bob’s ack) or
"federation.confirm.ack" (echoed back). Used to upgrade invited → trusted
on the initiator’s side and emit a final ack so the receiver can also pin
pending → trusted without manual intervention if policies.auto_accept is
true.
Revoke / Suspend (peer-to-peer)
Section titled “Revoke / Suspend (peer-to-peer)”POST /federation/revokeJWS body purpose: "federation.revoke" | "federation.suspend" | "federation.resume".
The receiver verifies the same way, finds its mirror federation_peer by
from_issuer, and transitions state. Tenant status follows: revoked →
archived, suspended → suspended, resumed → active.
Schema delta
Section titled “Schema delta”-- 0002_federation.sqlALTER TABLE identity.tenant ADD COLUMN kind varchar(255) NOT NULL DEFAULT 'local', ADD COLUMN federation_peer_id varchar(255), ADD CONSTRAINT tenant_kind_check CHECK (kind IN ('local','federated'));
CREATE TABLE IF NOT EXISTS identity.federation_peer ( federation_peer_id varchar(255) PRIMARY KEY, issuer varchar(255) NOT NULL UNIQUE, instance_id varchar(255), display_name varchar(255), state varchar(255) NOT NULL DEFAULT 'invited' CHECK (state IN ('invited','pending','trusted','suspended','revoked')), protocol_version integer NOT NULL DEFAULT 1, peer_metadata jsonb NOT NULL DEFAULT '{}'::jsonb, jwks jsonb NOT NULL DEFAULT '{}'::jsonb, jwks_cached_at timestamptz, pending_nonce varchar(255), pending_nonce_exp timestamptz, trusted_at timestamptz, suspended_at timestamptz, revoked_at timestamptz, tenant_id varchar(255) REFERENCES identity.tenant(tenant_id), created_at timestamptz NOT NULL DEFAULT NOW(), updated_at timestamptz NOT NULL DEFAULT NOW());
ALTER TABLE identity.tenant ADD CONSTRAINT tenant_federation_peer_id_fkey FOREIGN KEY (federation_peer_id) REFERENCES identity.federation_peer(federation_peer_id) ON DELETE SET NULL;The federation_peer.tenant_id and tenant.federation_peer_id form a
1-to-1 link maintained by service code (no cyclic FK enforcement; we choose
which side is parent on each operation).
New error codes
Section titled “New error codes”| Code | HTTP | Class |
|---|---|---|
ERR-P01-FED-1001 | 401 | auth - federation JWS signature invalid |
ERR-P01-FED-1002 | 401 | auth - from_issuer mismatch (DID does not match presenter) |
ERR-P01-FED-1003 | 401 | auth - replay (jti/nonce already used) |
ERR-P01-FED-2001 | 422 | schema - invalid issuer URL |
ERR-P01-FED-3001 | 404 | business - federation peer not found |
ERR-P01-FED-3002 | 409 | business - peer already exists in non-revoked state |
ERR-P01-FED-3003 | 409 | business - peer state does not allow this transition |
ERR-P01-FED-4001 | 502 | external - peer discovery (.well-known/citizenry-peer) failed |
ERR-P01-FED-4002 | 502 | external - peer JWKS fetch failed |
ERR-P01-FED-4003 | 502 | external - peer handshake returned non-2xx |
ERR-P01-FED-5001 | 500 | invariant - nonce mismatch on confirm |
Threat model deltas
Section titled “Threat model deltas”- Issuer impersonation: mitigated by
did:webresolution + JWKS pinning. A peer that changes its public DNS keys cannot retroactively forge older handshakes becausepending_nonceis short-lived and bound to the JWS. - Replay: all handshake bodies require
iat/expand a one-timenonce, enforced via the existingjti_replaytable. - Trust laundering:
federation_peeris not transitive. A trust between A and B does not create trust between B and C even if A↔C is trusted. Each pair handshakes independently. - Tenant explosion:
policies.max_peersis advisory in protocol but enforced as a hard limit in the admin POST; default 256 per instance.
Drawbacks
Section titled “Drawbacks”- Operational complexity. Each Citizenry instance now has an
out-of-band identity (
instance_id, federation signing key) it must preserve across deploys. Losing the federation signing key means the peer must be re-handshaked. - Schema bloat.
tenant.kindbecomes a load-bearing discriminator that every consumer must remember to filter on. - Network surface. Public
/federation/*endpoints are a new attack surface; abuse mitigation (rate-limit per source issuer) ships in v1. - Asymmetric trust pain. If A revokes but B’s revoke webhook fails, the instances are temporarily inconsistent. We accept this; admin can re-issue the revoke or wait for the next nonce expiry.
Rationale and alternatives
Section titled “Rationale and alternatives”Considered: OIDC Federation (OpenID Federation 1.0). Rich, but anchored on
trust chains, signed metadata statements, and a discovery hierarchy that does
not match a flat peer mesh. Adopting it would require a “federation operator”
role we explicitly want to avoid. We borrow OIDC Federation’s vocabulary
(metadata, issuer) without its hierarchy.
Considered: ActivityPub-style federation. Too coupled to social-graph semantics (Actor / Inbox / Outbox). Federation here is identity-only, not content delivery.
Considered: a single global registry. Centralized, contradicts the sovereign-deployment thesis.
Why this design wins: smallest possible change that gives every existing identity primitive (audit, tenant membership, JWT verify) a federation-aware view, with no new authorization concept.
Prior art
Section titled “Prior art”- RFC 9457 - Problem Details (error envelope shape).
- RFC 8037 - Ed25519 for JOSE (signing).
- W3C DID Core 1.0 +
did:web(issuer resolution). - OpenID Federation 1.0 (draft) (concept-level inspiration; not adopted).
- Matrix server-server federation (request-signing pattern).
Unresolved questions
Section titled “Unresolved questions”- Should
policies.auto_acceptbe exposed as an admin-mutable setting, and where (env var, KV, or a dedicated table)? - JWKS rotation between peers - push notification, or pure polling? The MVP polls on every handshake.
- Federated principals - when do we materialize a
principalrow for an agent from a peer? Deferred to RFC-0002.
Future possibilities
Section titled “Future possibilities”- Pull-based federated principal sync (RFC-0002).
- Per-peer access scopes (e.g., “Bob’s agents may read Salon posts, may not comment”).
- Discovery aggregator service for finding peers (off-protocol, optional).
- Multi-instance HA: same
instance_idacross replicas, keyed by federation signing key.
References
Section titled “References”docs/reference/error-codes/guideline.md- error-code format used by the codes above.packages/identity/src/db/schema.ts-tenanttable this RFC extends.packages/spec/identity/admin.tsp- existing admin auth pattern (PSK).packages/spec/identity/main.tsp- namespace this RFC adds endpoints to.