Multi-hub sync (CRDT) research¶
Synapse today is single-hub authoritative: one hub owns presence, claims, the board, and the durable event log, and every agent talks to it. This research lane asks whether several hubs — across hosts or domains — could synchronise their coordination state while keeping the two invariants that make Synapse worth using: claim safety (authoritative hubs do not grant overlapping live file scopes) and local-first (a hub stays correct and usable offline for the work it owns).
The honest answer drives the whole design: most coordination state can be merged conflict-free, but claims cannot — granting a lease is mutual exclusion, the exact opposite of a conflict-free merge. So the design splits state by what is actually CRDT-suitable and routes claims through ownership, not gossip.
Runtime status¶
What exists is single-hub plus operator-managed peering, not state sync:
- One
SynapseHubis the authoritative source of truth; the durable SQLite event log is replayed on restart so a single hub already survives crashes. - Multi-host deployments can use mutual TLS peer trust bundles (see signed events and mTLS) to authenticate peer hubs, and the federated trust model scopes which domain owns which project namespaces.
- The relay log and
synapse ingestexport the event stream read-side, which is the seam a downstream consumer (or a peer) could replay.
The full multi-host sync now routes a real claim to its owning hub and detects an ownership
partition at runtime from the peers observed asserting a namespace. The hub populates that
feed itself: synapse hub --multihub-watch PEER=URI (repeatable, requiring
--namespace-owner) runs a standing follower (core/multihub_watch.py) that polls each
named peer on a bounded interval and folds the observed claims into the assertions the
ownership gate consumes. Naming a peer is the operator confirmation for the always-on
outbound connection — nothing is auto-discovered — and a failed poll keeps the last
successful observation, so an outage never lets a contested namespace silently resume
granting. When the hub has a durable journal, entering or changing a contested
namespace appends multihub_partition at FULL durability; a successful,
non-empty history refresh from every persisted contestant that proves the namespace
uncontested appends multihub_heal. An empty or failed refresh is not healing evidence. An
unresolved partition is restored into the refusing assertion feed after restart,
and an entirely failed poll cannot manufacture a heal. Both events project through
the universal-receipt view as federation evidence without changing coordination
replay. An operator can still wire the feed by hand (asserting_owners over a
follower's view) in library deployments. The
honest-peer read-side merge layer, the cross-host event-log pull that lets one hub observe another over
a real connection, the serving-side gate that lets a hub refuse to serve its log to an
untrusted peer from the live connection, the claim-forwarding path that routes a claim to
its owning hub and relays the verdict, and runtime partition detection that refuses a
contested namespace, have shipped:
core/multihub_merge.py— the content-bound event-log union: it tags each event with its authoring hub, binds(hub_id, seq)to a canonical fingerprint of the complete event, merges honest append-only logs into a grow-only set, replays them in the deterministic(ts, hub_id, seq)order, and reports the per-hub resume cursor. Exact duplicates collapse; different content at one identity raises a typed equivocation error and no arrival-order or timestamp winner is selected.core/multihub_fold.py— folds that merged order into the observed mergeable view: the board (verified causal heads, then display-only last-write-wins among unresolved heads), the grow-only progress ledger, and the observed claim view — the latest claim each peer reports, tagged with its hub, marked advisory, cleared on release, and never granted. The board contract is explicitly non-authoritative: JSON exposes its policy, complete event fingerprint, and each displayed task's winning(timestamp, hub_id, seq)provenance. Task writes may name one content-bound parent. The fold verifies the parent event identity, complete-event fingerprint, and task id before removing that proven ancestor from the candidate heads; a unique causal head wins even when its clock is behind. It emits a payload-free unresolved conflict when remaining heads have different task-record fingerprints. Missing, malformed, or mismatched parents suppress nothing. The conflict proves disagreement, not concurrency: absent edges and NTP/clock-skew metadata are not vector clocks or causal proof. Partition detection uses a stricter sibling fold keyed by(hub_id, task_id), so a release from one hub cannot erase another hub's equal-named task from the authority signal even though the general display view remains last-writer-wins by task id.core/multihub_follower.py— a read-onlyMultiHubFollowerthat tracks a per-peerseqcursor, fetches a peer's events past it through an injected transport (store_fetcherreads a peerEventStoreover itsread_sinceseam), folds the union, and returns the observed view. Network fetchers also expose the peer's latest wire-version negotiation and welcome-frame clock skew, so a follower can report whether timestamp-ordered evidence is being read across materially skewed hubs. Observe-only by construction: it grants no claim and, losing a peer, simply stops advancing that peer's cursor — the fail-closed posture. The complete fetched batch is validated before publication: an equivocation freezes the prior view and cursor, quarantines the peer, and, when a journal is configured, records bounded digest-only evidence. Quarantine survives restart and only an explicit operator recovery naming a new log generation or accepted checkpoint clears it. Gaps and unseen rollback sequences also fail before publication. It is exposed to operators assynapse multihub observe(the walkthrough below).core/multihub_wire.py,core/handlers/multihub.py, andcore/multihub_transport.py— the cross-host pull: a request/snapshot message pair on the hub server lets a peer ask for the events past a cursor, andnetwork_fetcherdrops a network reader into the same follower in place ofstore_fetcher.core/multihub_federation.pygates the pull deny-by-default (federation policy composed with mTLS peer verification), so a follower only pulls from a granted, cert-pinned peer. Exposed assynapse multihub follow.core/multihub_serving.py— the serving-side mirror of that gate. A hub configured with aMultiHubServingPolicyreads the certificate the peer presents on the live mutual-TLS connection and runs the sameauthorise_multihub_peercomposition the following side enforces; a peer with no operator grant, no client certificate, or an untrusted pin is answered with an empty snapshot — the same shape as "no new events", so the refusal leaks nothing. A hub with no policy refuses every peer — the gate is fail-closed by default.
The production CLI loads that policy through --multihub-serving-policy FILE.
The strict version-one JSON names one audited federation store, one client CA,
and exact sender/domain/namespace/signing-key grants. Relative paths resolve
beside the policy; unsafe, symlinked, duplicate, unknown, or inconsistent input
refuses startup. Native TLS requests a client certificate for multi-hub frames
while ordinary local clients may still connect without one; the multi-hub gate
then denies a missing, revoked, wrongly pinned, or out-of-scope identity.
The policy, federation store, and client-CA file must each be owned by the
effective hub service user and use mode 0400 or 0600. Core captures all
three through full-component no-follow descriptor reads; OpenSSL receives the
captured CA bytes and never reopens the configured path.
Content identity, quarantine, and recovery¶
The honest-peer convergence contract requires one immutable value for every
(hub_id, seq). Core fingerprints the authoring hub, sequence, exact timestamp,
event kind, and recursively type-tagged payload. Mapping insertion order does not
change the digest; payload type changes do. Wall-clock time remains display metadata
and never resolves a collision.
If an authenticated, pinned, or signed peer presents a second fingerprint for an
accepted identity, authentication does not excuse the conflict. The follower validates
the whole candidate batch before replacing its event union, cursor, protocol metadata,
or clock-skew metadata. It then refuses the batch, retains the prior observed state,
and blocks subsequent automatic polls for that peer. A durable watcher appends one
multihub_equivocation record with the peer, sequence, both SHA-256 fingerprints,
local detection time, and observer identity; it never stores the event payload. These
rows and explicit recoveries project through universal-receipts as federation
evidence.
Recovery is a ceremony, not a reconnect. The operator must identify who accepted the
recovery, provide a reason (stored only as a digest), and name the log generation or
checkpoint the operator accepted. Core commits multihub_equivocation_recovery before
discarding that peer's old observed history and cursor. The next poll begins from zero
after that recorded ceremony; the current wire does not independently attest the
generation label. A stronger signed hash chain with previous-event digests and checkpoint
verification remains future work; this containment detects collisions and cursor-shape
violations but does not claim Byzantine consensus.
For the built-in durable watcher, stop the local hub and run:
synapse multihub recover --journal ./hub.db --peer-id west \
--operator incident-commander --reason "checkpoint verified" \
--new-log-generation west-generation-2
Restart only after that command succeeds. It refuses an unknown or already recovered peer and cannot silently clear a live process's in-memory quarantine.
{
"version": 1,
"federation_store": "federation.json",
"client_ca_file": "client-ca.pem",
"grants": [
{
"sender": "syn-west",
"domain_id": "west.example",
"namespace": "PROJECT",
"signing_key_id": "PROJECT:hub:2026-07"
}
]
}
Outbound secure routes share one paired owner-only identity configured by
--multihub-client-certfile and --multihub-client-keyfile. Every such watch,
claim, or relay route must also carry its named SHA-256 server pin. Claim and
relay maps stay independent, and the relay connector is also used for the
content-free dead-letter escalation pointer. Captured client material is loaded
at process startup; rotate it by restarting the Core hub.
Use --require-relay-reason to make every governed action explain itself and
--require-two-person-relay to hold it for a distinct trust-domain principal;
these policies apply equally to direct and routed relays. On a journal-backed
hub, a validated inbound pending audit restores only the first-principal quorum
after restart. A later terminal audit removes it, and malformed or contradictory
evidence restores no quorum. Keyed pending, applied, and completed-quorum no-op
verdicts exact-replay only after the current peer certificate, relay scope, and
namespace ownership are authorised again.
core/multihub_watch.py— the hub's own standing follower: polls each operator-named peer over that same pull, folds the observed claims with the gate's namespace derivation, and holds the per-namespace asserting-hub view partition detection consumes. Opt-in via--multihub-watch; fail-closed for authority (a failed poll keeps the last successful observation).observed_peers.py— the operator-surface adapter for the same read-only pull:synapse who,synapse status,synapse state, andsynapse dashboardaccept repeatable--observed-peer HUB=URIflags, fold each peer locally, and render the result asobserved@HUBadvisory state without changing the local hub. When the peer advertises a usable welcome timestamp, the row and JSON payload include local-minus-peer clock skew alongside cursor lag.
Observing a peer — a two-hub walkthrough¶
The read-side layer above lets one operator observe another hub's coordination with no cross-hub service running. On a single machine — or any shared filesystem — run two hubs with separate event stores, do some work on each, and read the other's state.
1. Run two hubs¶
Each hub owns its own durable event store (--db):
synapse hub --port 8876 --db ./east.db &
synapse hub --port 8877 --db ./west.db &
2. Coordinate on each¶
Declare a task on each hub, and claim a file scope on one:
synapse task declare build --title "Build the wheel" --uri ws://localhost:8876
synapse git-claim build --paths src/ --uri ws://localhost:8876
synapse task declare docs --title "Write the docs" --uri ws://localhost:8877
3. Observe the peer¶
East's operator reads west's coordination, read-only, straight from its event store:
synapse multihub observe --peer-db ./west.db --peer-id west
observing peer 'west' — 1 tasks, 0 progress notes, 0 observed claims
board (verified causal heads; display LWW among unresolved heads — non-authoritative):
[open] docs — Write the docs [display source west#1 @ 1]
And west's operator observes east — including east's claim, which appears as an observed claim, never granted locally:
synapse multihub observe --peer-db ./east.db --peer-id east
observing peer 'east' — 1 tasks, 0 progress notes, 1 observed claims
board (verified causal heads; display LWW among unresolved heads — non-authoritative):
[open] build — Build the wheel [display source east#1 @ 1]
observed claims (advisory — not granted):
build -> <agent> @ east
observe reads the peer's event store through the same read_since seam the follower
uses — SQLite WAL lets it read alongside the live peer hub — and prints the folded state.
It grants nothing: a peer's claim is advisory here, and a real claim is still made on the
owning hub. Add --json for a machine-readable ObservedState. The JSON retains the
existing board records and adds board_policy plus per-task board_provenance, including
the complete event_fingerprint needed by task declare|update --causal-parent. Consumers
must not use the display winner as authoritative task truth. A causal: true provenance
means a verified parent chain selected a unique head, not that the observed board grants
local authority.
4. Follow a peer over the network¶
When the peer is on another host with no shared filesystem, follow pulls its event log
over a real connection instead of reading a file. It asks the peer for the events past a
cursor and folds the same observed view:
synapse multihub follow --peer-uri wss://west.example:8876/ --peer-id west
observing peer 'west' — 1 tasks, 0 progress notes, 0 observed claims
board (verified causal heads; display LWW among unresolved heads — non-authoritative):
[open] docs — Write the docs [display source west#1 @ 1]
follow is the network counterpart of observe: it drops a network fetcher into the same
follower, so it grants nothing either and a peer's claim stays advisory. Pass --token for
a secured peer hub, --limit to bound the batch, and --json for the machine-readable
ObservedState. Whether a follower may pull from a peer at all is gated deny-by-default by
the federation/mTLS policy (see Boundaries); the library API
peer_authoriser composes it and the fetcher fails closed for an ungranted peer.
For a wss:// peer whose certificate is self-signed or private-CA, pass
--pin sha256:<hex> to trust the peer by its live certificate pin instead of a
CA chain — the same pin recorded in the peer's federation bundle and printed by
synapse federation fetch:
synapse multihub follow --peer-uri wss://west.example:8876/ --peer-id west \
--pin sha256:9b1f63a2c8d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0
The pin is compared against the certificate presented on the TLS socket before
any snapshot frame is decoded; a mismatch, a missing certificate, or a plaintext
ws:// URI fails the pull closed and the follower's cursor stays unadvanced.
The library counterpart is
synapse_channel.core.multihub_transport.pinned_connector.
On connect, the network fetcher also reads the peer hub's advertised
welcome.protocol_version. A mismatch does not fail the pull: the fetcher logs an
operator-visible warning, records the negotiated lowest-common wire version for
the MultiHubFollower, and keeps optional features gated to that effective
version. Peers that omit the field are treated as legacy wire version 1.
4a. Add observed peers to operator surfaces¶
The day-to-day operator surfaces can show the same advisory peer state without
switching to the dedicated multihub command:
synapse who --observed-peer west=wss://west.example:8876/
synapse status --json --observed-peer west=wss://west.example:8876/
synapse state --observed-peer west=wss://west.example:8876/
synapse dashboard --observed-peer west=wss://west.example:8876/
Each command fetches the peer with the multi-hub log request path, folds the
events locally, and labels output observed@west. A peer outage renders an
unreachable peer row; it does not make the local hub unhealthy and does not
grant, revoke, or route a local claim. Use --observed-token for secured peers
and --observed-timeout to bound each pull. For a self-signed wss:// peer, add
--observed-pin HUB=sha256:<hex> (repeatable) — the same pull-side certificate
pin as multihub follow --pin; a pin naming a hub that --observed-peer does
not fetch is refused, so a typo cannot silently leave the real peer unpinned. When the peer's welcome frame
carries a finite timestamp, who rows include skew=+/-Ns, status includes
the largest observed absolute skew, and JSON outputs carry
clock_skew_seconds.
5. Trace causality across the hubs¶
With both logs at hand, synapse causality causes ./east.db west:9 --peer west=./west.db
answers the causality queries over the merged logs: events keep their global identity
hub:seq, the recorded relations (lifecycle, dependency, contention) are derived over the
deterministic merged order, and an edge whose endpoints two different hubs authored is
tagged federation with the underlying relation as its basis — so a release on one hub
that satisfied a depends_on claimed on another renders as federation:dependency. The
same honesty boundary as the fold applies: cross-hub precedence is ordered by event
timestamps across the hubs' clocks (there is no shared sequence), so a federation edge is
clock-ordered evidence, and the query observes and grants nothing. See
the CLI guide for a worked cross-hub example.
If the operator has measured peer skew from synapse doctor --federation-peer
or an observed-peer pull, pass it into the offline causality report:
synapse causality causes ./east.db west:9 \
--peer west=./west.db \
--clock-skew west=-6.4 \
--skew-warn-seconds 5
The report then adds a clock-skew warning when the absolute skew exceeds the
threshold; --json carries the same warning in clock_skew, and --dot emits
it as a Graphviz comment. The warning does not change the graph. It tells the
operator that cross-hub ordering is timestamp evidence outside the configured
clock-agreement bound.
Where this stops¶
The cross-host event-log pull above now ships, so a hub can observe another over a real
connection, and the federation/mTLS gate is now enforced on both sides — the following
side before it pulls, and the serving side before it serves (MultiHubServingPolicy, see
Boundaries). Opt-in claim forwarding also ships: a hub configured with
--namespace-owner and --claim-peer HUB_ID=URI routes a remote-owned claim to that
namespace's owning hub and relays the owner's verdict. What remains outside this design is
automatic peer discovery, global consensus, and a merge that could turn observed peer state
into local claim authority.
State, split by what merges¶
Coordination state is not one thing; each kind has a different merge story.
- Durable event log — append-only and the natural sync unit. Each hub's events
carry a hub id and a per-hub monotonic sequence, so the union of two logs is a
grow-only set keyed by
(hub_id, seq). Replaying the union in(timestamp, hub_id, seq)order is deterministic, but that total order is not a vector clock and does not establish cross-hub causality. The grow-only union is the one piece that is genuinely CRDT-shaped. - Presence — last-writer-wins per agent, keyed by hub id; an agent is present on the hub it connected to, and a peer view is advisory.
- Progress notes and the board plan — notes are grow-only. The observed board
verifies optional content-bound, same-task parent references and removes proven
ancestors. It uses whole-record LWW ordered by
(timestamp, hub_id, seq)only among the remaining unresolved heads, with visible winner provenance. It is useful for a convergent operator view but is not authoritative task state; unrelated or incompletely linked heads remain unresolved and are not called concurrent. Their fallback LWW display selection remains non-causal. A clock-ahead older ancestor cannot override its verified causal descendant. - Capability cards — LWW per agent id, mergeable.
- Claims — not mergeable. A claim is a lease that must be unique per file scope; two hubs independently granting the same scope is precisely the collision the claim exists to prevent. Claims need ownership or consensus, never a merge.
Claims without a merge¶
Because claims are mutual exclusion, the design routes them by namespace ownership, reusing the domain model the federated trust model already defines:
- Each project namespace has exactly one authoritative hub at a time. Claims within a namespace are granted only by its owning hub, so there is never a conflicting grant to merge.
- A peer hub does not grant claims for a namespace it does not own; it can read the owning hub's claim state (advisory, eventually consistent) but routes a real claim request to the owner.
- Cross-namespace work that needs scopes from two owners is a coordinated, explicit hand-off between owners, not a silent merge.
- If two hubs ever both believe they own a namespace (a partition), the safe default is refuse to grant until ownership is re-established — claim safety fails closed, never open.
This keeps the strong invariant local: each hub grants claims authoritatively for its own namespaces with no network round-trip, and only the observed view of other namespaces is eventually consistent.
The resolution behind this rule ships in core/namespace_ownership.py: a
NamespaceOwnership map records the single owning hub per namespace and resolves a
namespace to local (grant here), remote (a named peer owns it), ungoverned, or
partitioned, the last two failing closed. A hub configured with such a map enforces
it on the grant path — a claim whose namespace (derived from the agent identity, as the
ACL derives it) the hub does not own is refused with a claim_denied naming the owning
hub, so the caller knows where to route it; a hub with no map grants every namespace, as
a single hub does today. The networked half now ships too, opt-in: a hub configured with
claim_peers (a route to each owning hub) forwards a remote-owned claim over a connection
through core/multihub_claim_transport.py, the owner grants it on the serving side
(core/handlers/multihub_claim.py), and the verdict is relayed to the claimant — a grant
carrying the owner's authentic lease. An unreachable owner falls back to the refusal that
names the owner, fail-closed. Runtime partition detection ships too, opt-in: a hub wired with
an observed_asserting_hubs feed resolves a namespace a peer is observed contesting to
partitioned and refuses the claim, even on its own local grant path; asserting_owners
builds that feed from a follower's observed claims. What is not yet built is the hub
auto-discovering peers: the hub populates the feed only from the operator-named standing
follower configured by repeatable --multihub-watch PEER=URI flags.
Sync transport¶
Sync rides the existing seams rather than inventing a new protocol surface: a peer
replays another hub's event log from a cursor (the ingest/relay seam),
authenticated by the mTLS peer trust bundle, and folds the mergeable state in.
Because an honest append-only log is the CRDT-shaped unit, "sync" is mostly "replay
the peer's log since my cursor, verify its content bindings, and apply the folds";
conflicting identities fail closed instead of merging, and only namespace-ownership
changes need an explicit, operator-confirmed step.
Local-first guarantee¶
Every hub stays fully usable for the namespaces it owns with no peer reachable: claims, presence, board, and log all work offline. Sync adds an observed view of peer namespaces and a merged history; it never makes a hub depend on a peer to do its own work, and it never lets a peer grant a claim inside a namespace it does not own.
Boundaries¶
The read-side (merge, fold, follower), the cross-host event-log pull (observe and
follow), the deny-by-default federation/mTLS gate on both the following and the
serving side (MultiHubServingPolicy reads the peer's live certificate), the
namespace-ownership resolution with its local grant-path enforcement
(NamespaceOwnership), the cross-hub claim forwarding that routes a remote-owned claim to its
owning hub and relays the verdict (claim_peers + forward_claim +
handle_multihub_claim_request), and runtime partition detection that refuses a contested
namespace when fed observed assertions (observed_asserting_hubs + asserting_owners) are
implemented — and so is the hub feeding those assertions itself from a standing follower of
its own (core/multihub_watch.py, opt-in via --multihub-watch). The design stays
deliberately conservative.
- Claims are not a CRDT. Mutual exclusion is not conflict-free; the design uses
single-owner-per-namespace, not claim merging, and fails closed on an ownership
partition. The ownership resolution, the local refusal of an unowned namespace, the
forwarding of a remote-owned claim to its owner, refusing a contested namespace on
observed assertions, the hub's own standing follower feeding those assertions
(
--multihub-watch), and durable partition/heal transition evidence all ship. - The merge assumes honest append-only history, not Byzantine consensus. Exact
duplicates are idempotent. A reused
(hub_id, seq)with different timestamp, kind, or payload is durable equivocation evidence: the candidate view and cursor stay unpublished and the peer is quarantined. mTLS, pins, and frame signatures authenticate the presenter but do not choose a winning history. - The observed board is not authoritative. A valid task parent is a bounded causal assertion: it is accepted only when the named complete event is present, content-bound, and for the same task. That edge can suppress its proven ancestor; it cannot grant a claim. Remaining heads still use deterministic LWW only for display. Missing edges do not prove concurrency, and synchronized clocks, including NTP, do not establish a parent relation. Local board truth, claim authority, and the fail-closed namespace ownership resolver do not consume this display choice.
- It does not add a new always-on wire surface casually — the pull is a request/snapshot
message pair on the existing hub server, reusing the event log,
read_sinceseam, and mTLS peer bundles; it adds no always-on cross-hub service to the local core. - It does not weaken local-first: a hub never depends on a peer to grant its own claims or run its own work.
- It does not introduce a global consensus cluster. There is no single global leader; authority is partitioned by namespace, each hub local-authoritative for its own.
- It makes no multi-host claim-safety guarantee for two default/unconfigured hubs: the cross-host pull is observe-only. Multi-host mutual exclusion requires an explicit, consistent namespace-owner map plus the standing watch; the durable transition events evidence that posture but do not replace it with consensus.