Pair two stewards in one command
Use `spl federation pair` to establish a record-bound persistent relationship between two instances. Replaces hand-wired URL+DID+token plumbing with a single CLI command.
What this is
A steward is any running Syncropel instance — your laptop instance, a hosted instance at <label>.syncropel.app, or a self-hosted server you run for a project. Two stewards can be paired so they sync records consent-gated.
spl federation pair <peer-url> is the one-command way to establish that pair between two stewards. The command:
- Discovers and verifies the peer's signed manifest at
/.well-known/syncropel. - Runs a 3-roundtrip handshake (nonce-protected against replay).
- Mints reciprocal service accounts and bearer tokens — each instance gets a token bound to the peer's DID, so one peer can never impersonate another actor.
- Persists signed pair records on both sides on the reserved thread
th_federation_pairs. - Returns. From that point on,
spl syncconsults the pair store automatically — no--peer-tokenflag, ever.
This guide assumes you have two instances (call them alice and bob), each with cryptographic identity configured, and you can reach each other's URLs.
Prerequisites
- Both instances are running a recent version of
spl:spl version. - Each instance has a cryptographic identity. Run
spl identity show— if it says "no identity," runspl identity generate. - Each instance's URL is reachable from the other. For local two-instance setups, use
--insecure-localhoston both. For cross-host, both should serve TLS. - Optional but recommended:
spl identity verifyon each side first to make sure the local key matches the instance's resolved DID.
The headline command
From alice's host:
spl federation pair https://bob.example.comThat's it. Output looks like:
✓ Discovered https://bob.example.com
DID: did:sync:instance:bob-fedb12...
Manifest TTL: 6d 23h remaining
→ Initiating handshake (nonce: 8f4a...)
→ Awaiting peer approval...
[on bob's side, an operator runs `spl aitl approve <id>`]
✓ Pair confirmed
pair_id: fed_<sha256-of-both-DIDs>
scopes: federation:sync_pull, federation:sync_push, federation:subscribe
token TTL: 90d (auto-renew at 83d)
state: active
Persisted at: th_federation_pairs (genesis record on both sides)
Try: spl federation list
spl sync th_workspace from did:sync:instance:bob-fedb12...The default posture is manual approval on both sides. The operator on bob's host sees the pending request via spl aitl list and approves with spl aitl approve <id>. Once approved, bob mints the SA + token and the result becomes retrievable by alice via the pair status poll.
For trusted environments where you want auto-approve (e.g., a fleet of stewards under one operator), see Auto-approval via CEL below.
What happens during operator approval
When alice runs spl federation pair add <peer-url> and bob's posture is manual-approval:
- Bob receives the handshake POST, validates the manifest, and emits a
pair_pending.v1AITL record onth_federation_pairs. Bob's response:202 Acceptedwith thepair_idandaitl_record_id. - Alice's CLI auto-polls
POST /v1/federation/pair/<pair_id>/poll(with the original nonce) on a 2-second interval, up to a 60-second window. The poll endpoint is unauthenticated; the nonce serves as the knowledge-factor proof that the caller is the original initiator. - Bob's operator runs
spl aitl approve <id>. The AITL decide handler dispatches onbody.aitl.kind(syncropel.federation.pair_pending.v1) → calls the federation pair completion path → mints SA + token, emits a signedpair.genesis.v1on bob'sth_federation_pairs, stores the result in bob's pending-pair-approvals map (5-minute TTL). - Alice's next poll receives
state: "active"along with the issued token + responder manifest. The pending-pair-approvals entry is consumed (one-shot — the plaintext token does not linger in bob's memory beyond a successful poll). - Alice's CLI continues with the post-init flow: persists the local genesis on her
th_federation_pairs, stores the peer credential locally, sends the confirm POST to bob.
If alice's 60-second poll window elapses without approval (operator on PTO, weekend, etc.), her CLI prints a hint and exits 0:
! still pending after 60s; re-run `spl federation pair add` once the peer operator approvesRe-running creates a new pair_id (new nonce); the stale AITL on bob's side can be aitl reject-ed manually or will TTL out.
The full lifecycle
After pairing, the new spl federation subcommands manage the relationship:
spl federation list # all pairs + state column
spl federation show <peer-did> # detail: tokens (IDs), grants, last-sync
spl federation pause <peer-did> # active → paused (sync calls fail with pair_paused)
spl federation resume <peer-did> # paused → active
spl federation refresh <peer-did> # re-fetch peer manifest
spl federation revoke <peer-did> --reason X # any → revoked (terminal)States transition based on what's happening:
- establishing: handshake in flight (no genesis record yet)
- active: handshake complete, peer reachable
- paused: operator paused via
spl federation pause - degraded: peer unreachable >24h (auto)
- revoked: terminal (operator revoked, or peer notified revoke)
The state machine is fold-derived from records on th_federation_pairs. A 90-day no-contact threshold auto-archives degraded pairs.
How spl sync changes
The old way, with manual peer-token plumbing:
spl sync th_workspace from https://bob.example.com --peer-token sk_... # tediousThe new way, after pairing:
spl sync th_workspace from did:sync:instance:bob-fedb12... # uses pair storeThe --peer-token flag is intentionally NOT added to spl sync. If you try to sync from a peer URL with no matching pair, you'll get:
ERROR: unpaired_peer
https://bob.example.com is not paired with this instance.
Run: spl federation pair https://bob.example.comIf your peer hits a 401 on outbound sync, your pair transitions to degraded. The next successful call transitions back to active automatically. Audit emission on every transition.
Cross-namespace consent
By default, pairing only allows sync within namespaces both peers already share. Cross-namespace record sharing requires an explicit consent grant:
spl federation grant <peer-did> \
--namespace research \
--maps research,my-team \
--hash-level L1This composes with namespace consent — pair authorization is the transport layer, consent grants are the data-policy layer. They're orthogonal.
To revoke a specific grant without revoking the pair:
spl federation revoke-grant <peer-did> --grant-id <id>Sync mode + thread filtering
Per pair you can configure how often to sync:
spl federation set-mode <peer-did> --mode polling # 5min default poll
spl federation set-mode <peer-did> --mode continuous # SSE
spl federation set-mode <peer-did> --mode on-demand # only when explicitly run
spl federation set-poll-interval <peer-did> --interval 1mAnd what threads sync:
spl federation thread-allow <peer-did> --thread th_workspace
spl federation thread-rule <peer-did> --expression 'thread().id.startsWith("th_research_")'Thread rules accept any CEL expression with record + thread() bindings.
Auto-approval via CEL
For trusted contexts (fleet stewards under one operator, known-org pairs, CI test environments), you can configure auto-approval via a CEL aitl_rule:
spl config add-aitl-rule \
--name auto-approve-trusted-fleet \
--action auto_apply \
--priority 100 \
--expression 'record.body.aitl.kind == "pair_pending.v1" && record.body.peer_did.startsWith("did:web:my-org.example.com/")'This auto-approves any pair handshake where the peer DID starts with your org's domain. The handshake completes immediately without operator intervention.
Default: no auto-approval. Trust comes from explicit handshake decisions.
Token rotation
Pair tokens default to 90-day TTL with auto-renewal at 83 days (7-day window). The instance detects the upcoming expiry, runs a lighter handshake-refresh with the peer, and emits update records on both sides with new token IDs. The old token is revoked at issuance time — no overlap window.
If automatic refresh fails:
- 1 day before expiry: emits a
pair_renewal_failedaudit record - At expiry: sync calls fail with
token_expired_renew_pair, pair transitions todegraded - Operator runs
spl federation refresh <peer-did>to manually re-handshake
Auditing
Every state transition is recorded on th_audit_federation_pairs. Subscribe to that thread for an operator-facing stream of pair lifecycle events:
spl thread records th_audit_federation_pairs -o json | jq '.[] | {when: .body.timestamp, peer: .body.peer_did, transition: .body.transition}'Audit records are separate from the pair-state records on th_federation_pairs — the pair-state records drive the computed state; audit records are for humans.
Troubleshooting
"peer served /.well-known/syncropel but no federation_manifest key" — Peer doesn't have identity configured. Run spl identity generate on the peer side.
"pair_handshake_rejected: unauthenticated_initiator" — TLS verification failed. If you're testing locally without TLS, both sides need --insecure-localhost.
"pair_paused" on spl sync — Pair is paused. Run spl federation resume <peer-did>.
"unpaired_peer" — You're trying to sync from a URL or DID that doesn't match any pair record. Run spl federation pair <peer-url> first.
Peer returns 410 Gone — Replay attack defense kicked in. Re-run the original spl federation pair command; nonces are single-use.
What this enables
The pair primitive is the gate to several things:
- N stewards as a real network, not a fleet of disconnected islands
spl syncwithout manual token plumbing — the end of the antipattern- Auditable trust relationships — pair lifecycle is records, not config; survives
spl export/spl import - Composable with consent — pair is transport, consent is data policy; each is independent
See also
- Federation discovery — finding peers by handle or directory
- Async federation — record sync via relay when peers are offline
- Concepts: Federation — the model (immutable records + pull-based sync + explicit consent)
- Consent — cross-namespace record sharing rules
Federation — pairing two instances
Set up record sync between two Syncropel instances. Covers identity setup, creating pairs, how propagation works, cross-namespace consent, and the common misconfigurations.
Invite another steward (composed)
Use `spl federation invite` to pair, grant, and allow threads in one composed command. The single-verb adoption flow that wraps the underlying primitives.