Skip to content

Wire protocol

Every message is a JSON envelope with a small, fixed shape: sender, target, type, payload, and a timestamp; hub-originated messages also carry a hub_id. The type field selects the message; the values, grouped by concern, are below.

For inbound chat frames the hub overwrites timestamp with its own wall clock. That hub stamp is the only value used to order retained chat history and the dead-letter ledger. A finite client-supplied instant is kept only as optional advisory metadata on client_timestamp; non-finite or malformed client values are discarded. A Byzantine future or backdated client stamp therefore cannot poison history or dead-letter ordering.

A state-mutating message may carry an idem_key so a retry after a reconnect is applied once. On a secured hub, the first message of a connection must carry a token.

The hub advertises its wire-protocol version in the welcome handshake as protocol_version (an integer; the current wire is version 2), and it is also reported by /health as protocol_version. It is decoupled from the package version on purpose — a patch or feature release that leaves the wire shapes unchanged does not bump it, so it is a stable compatibility signal a client can read on connect rather than a release counter. Version 2 added the client → hub ack verb and the deferred delivery receipt it drives (see Directed delivery and the mailbox); a client emits an ack only when the peer advertises version 2 or newer, and a hub that predates the verb is never sent it, which is what keeps the addition backward-compatible. A client that predates the field, or a hub that does, reads it as absent. Version-skewed peers are accepted rather than rejected: consumers negotiate to the lowest common wire version, warn the operator when the peer is older, newer, or did not advertise a usable version, and gate optional features against that effective version.

The per-message authentication runtime keeps the same envelope shape and adds an auth object for selected mutating frames after WebSocket connect authentication. It is opt-in: --message-auth-key configures sender-bound HMAC keys, and --require-message-auth enforces signed claims, releases, task updates, handoffs, checkpoints, and resource offers.

Embedded hubs may instead verify the signature object defined by the signed-events runtime against an EventSignatureTrustBundle. The packaged hub CLI does not load that bundle; native --tls-certfile --tls-keyfile is server TLS and does not by itself enable signed events or mutual TLS.

The identity and ACL runtime keeps protocol messages as ordinary envelopes. Signed registration fields bind a connection name to a machine key or operator trust bundle, and opt-in ACL evaluation refuses unauthorised mutating frames before state changes. These additive fields and checks do not replace the connect token or change the default local wire flow.

The signed capability cards runtime keeps advertise and manifest_request as ordinary discovery messages while optionally adding a domain-separated Ed25519 signature and manifest digest. The hub binds the signed project to the connected sender's namespace and projects an explicit verification result. Verification remains advisory and does not turn capability cards into authorization or executable trust.

The planned differential-privacy blackboard design keeps ledger_task, ledger_progress, and board_request as ordinary local messages while defining future redacted or noisy projections for shared reports. It is not implemented yet and does not anonymize raw event logs.

The agent trust graph (synapse trust-graph) keeps the wire protocol unchanged. It reads existing event-log records and release receipts as graph evidence for routing review, entirely on the read side; it does not add agent grades to protocol envelopes.

Agent → hub

  • Presence and chat: chat, heartbeat (sent automatically by clients).
  • Directed delivery: ack (acknowledges a mailbox-accepted live or replayed directed message by its durable seq, optionally naming mailbox_for; see Directed delivery and the mailbox).
  • Claims and leases: claim, release, task_update, handoff, checkpoint, wait_request.
  • Resources: resource.
  • Shared blackboard: ledger_task, ledger_task_update, ledger_progress, board_request.
  • Capabilities: advertise, manifest_request.
  • Queries: state_request, who_request, history_request, resume_request.
  • Governed operator recovery: identity_pin_reclaim removes one exact TOFU pin after the always-on ACL, requester-binding, owner-liveness, expected-key, and durable-audit gates pass. It is emitted only by an explicit operator command, never automatically by a client.
  • Guard evidence: guard_denial admits one content-minimized native file-guard refusal; guard_denial_recorded acknowledges its durable sequence. The authenticated durable contract is defined below.

Canonical claim-path identity

A claim may carry the additive path_identity object below. It does not bump the wire version because it is optional, is omitted by legacy clients, and is ignored by clients that only render the ordinary worktree and paths fields.

{
  "version": 1,
  "worktree_path": "/canonical/repository",
  "worktree_object_id": "device:object",
  "filesystem_namespace": "sha256:opaque-host-namespace",
  "case_sensitive": true,
  "paths": [
    {
      "git_path": "src/auth.py",
      "filesystem_path": "src/auth.py",
      "object_id": "device:object",
      "object_scope": ""
    }
  ]
}

Each nested row aligns one-to-one with the claim's display paths and is client-derived from the local Git index and filesystem. Comparison strings are repository-relative Unicode NFC; case folding occurs only for an insensitive worktree. Device/object values are compared only when both identities carry the same opaque filesystem namespace and worktree-root object key; this prevents coincident inode values on different hosts from aliasing. Empty object ids mean the path does not yet exist. The optional object_scope is empty or absent for the whole object and otherwise carries a canonical semantic descendant. A whole object conflicts with every descendant, declaration ancestry conflicts, and sibling declarations remain independent across hard-link aliases. Object comparison is conflict-only: it can deny a competing hard-link claim, but cannot widen edit authorization or auto-release because inode identity is not a historical capability. The hub rejects an unsupported version, malformed values, or any row that does not match its display path before changing state. It then persists and replays the field in claim, handoff, journal, causality, conflict, yield, and staged-check projections; Git-hook release matching can consume it client-side. The hub never trusts the identity as authorization and never uses it to access a local path.

When only one side supplies the field, the hub projects the legacy display under the supplied filesystem policy. Two claims without the field retain the version-2 literal-path behavior. This supports rolling upgrades but means a fleet closes every alias gap only after all Git-aware claim producers upgrade.

Durable claim-denial evidence

When a hub has a durable event journal, every refusal made by its authoritative local claim application appends a claim_denial event with durable=true. This includes a forwarded claim that the owning hub applies locally. For a direct client, the append completes before the private claim_denied reply is emitted. The reply carries the same stable reason_code: TASK_ID_REQUIRED, PATH_IDENTITY_INVALID, LEASE_LIVE, SCOPE_CONFLICT, QUOTA_EXCEEDED, or the conservative fallback CLAIM_DENIED.

The evidence row is deliberately content-minimized. It contains the bounded claimant identity plus its full digest, decision and reason, path count, and SHA-256 correlations for the task id and declared scope. It never stores the request note, raw task id, raw worktree or paths, Git metadata, message bodies, prompts, or file contents. The event is audit-only during restart replay: it survives restart but cannot create or alter a lease.

Authenticated guard-denial evidence

A provider file-claim hook that denies a native mutation may open a separate, token-authenticated connection and send guard_denial. The shipped reporter reuses the same bounded, digest-only claim-hook/<owner>-<slot> connection name as its authoritative state query, so it does not create a second unbounded identity namespace. The hub accepts the verb only when both connect authentication and a durable journal are configured. An open hub has no credential principal to attest, and a journal-free hub cannot meet the durability contract, so either posture returns error_code: guard_evidence_unavailable without recording anything.

The request carries only a closed provider name, a closed reason_code, a bounded path_count, and lowercase SHA-256 values named actor_sha256, call_sha256, and scope_sha256. The allowed denial reasons are GUARD_NO_CLAIM, GUARD_NOT_EDITABLE, GUARD_OWNERSHIP_AMBIGUOUS, GUARD_STATE_UNREACHABLE, and GUARD_TARGET_INVALID. The frame's payload is empty. Raw actor/session/tool identifiers, paths, worktrees, branches, prompts, message bodies, and file contents are never sent by the shipped reporter and therefore never enter this audit row.

Those client digests are correlation metadata, not authenticated identity. The record's provenance is the server-derived connect-token principal on the bound socket; the hub stores only its SHA-256 digest plus a digest of the recorder name. Rotating an asserted sender name cannot rotate this credential bucket. With --require-acl, the sender also needs the evidence permission on target evidence:guard-denial.

An accepted request appends guard_denial with full durability before returning guard_denial_recorded {audit_seq, call_sha256, reason_code}. The verb supports the ordinary durable idem_key contract, so reconnect retries replay the first acknowledgement instead of appending a second denial. A fixed per-credential sliding window (100 events and 256 KiB per 60 seconds) rejects excess evidence before journal growth. Evidence reporting is supplemental and fail-closed: if the second connection or append fails, the original tool call remains denied. During restart replay the row is audit-only and cannot create or change a claim.

An advertise message may include contracts, either as a list of contract objects or as a task-class keyed mapping. The hub normalizes valid entries into the manifest shape:

  • task_class: the routing class the contract describes.
  • input_schema and output_schema: JSON-object mappings, usually JSON Schema fragments, describing accepted input and produced output.
  • preconditions and postconditions: optional lists of declarative checks.

Malformed contract entries are ignored rather than rejecting the advertisement. Capability contracts are discovery metadata for routing, dashboards, A2A Agent Card metadata, and human review; they do not execute checks, authorize callers, or certify external conformance. A signed advertisement may also carry manifest_digest and a signature envelope containing version, algorithm, key id, sequence, signing/expiry timestamps, card digest, and signature value. Both fields are additive: unsigned clients retain their legacy outbound shape, while projected cards expose project, manifest_digest, signature, and verification.

An advertise message may also carry the additive persist, dispatchable, and agent fields. With persist: true the card becomes a persistent dispatch registration: it survives the sender's disconnect and expires only when not refreshed within 24 hours, so automated dispatch can discover a project seat across reconnects. dispatchable (boolean, default true) opts the registration in or out of automated dispatch. agent names the identity the card belongs to; the hub honours it only when it equals the connection name or the connection is that identity's -rx sidecar (a wake listener registering its seat). Persistent registration requires a project-scoped seat identity (<project>/<seat>); non-boolean flags, a foreign agent, or an unscoped identity are refused with a private error. Projected cards in capability_advertised and manifest_snapshot carry the additive persistent/dispatchable keys, merged onto the agent's single manifest entry; legacy consumers ignore them.

A ledger_task may carry the additive project string (a namespace scope for the task; absent means unscoped, and a re-declaration with a conflicting non-empty scope is refused) and both ledger_task and ledger_task_update may carry the additive expected_version integer — a compare-and-set guard refused with a private error unless the task's current monotonic version matches (an absent task counts as version 0). ledger_task_update may also carry project to re-scope. Every accepted mutation increments version; task projections in ledger_task_posted, ledger_task_updated, and board_snapshot carry project and version. A non-integer expected_version (booleans included) is refused as malformed.

Both task write verbs may also carry additive causal_parent metadata with exactly hub_id (non-empty, at most 512 UTF-8 bytes), positive integer seq, and lowercase SHA-256 event_fingerprint. The hub validates and journals this reference outside the task snapshot, so ledger_task_posted, ledger_task_updated, and the local board retain their compatible task shape. The observed multi-hub fold accepts the parent edge only when the named complete event exists, its content-bound fingerprint matches, and it concerns the same task. Missing or mismatched references suppress nothing. A parent proves one recorded observation edge; absence of an edge does not prove concurrency.

Hub → agent

  • Session: welcome, presence_update, name_conflict, auth_denied, error, system.
  • Claims and leases: claim_granted / claim_denied, release_granted / release_denied, task_updated, handoff_granted / handoff_denied, checkpoint_saved / checkpoint_denied, wait_granted / wait_denied.
  • Resources: resource_offered.
  • Shared blackboard: ledger_task_posted, ledger_task_updated, ledger_progress_posted, board_snapshot.
  • Capabilities: capability_advertised, manifest_snapshot.
  • Queries: state_snapshot, who_snapshot, history_snapshot, resume_snapshot.
  • Operational warnings: recipient_liveness_warning, dark_seat_alert, dead_letter_escalation, dead_letter_forwarding.
  • Governed operator recovery: identity_pin_reclaim_result is the private applied/refused verdict for an identity_pin_reclaim request.

A dark_seat_alert is a default-on hub broadcast for an identity that owns an unexpired claim or is the suggested_owner of a non-terminal board task but has no fresh exact-identity -rx waiter. The condition must persist for 30 seconds; the hub then emits one alert per continuous episode with sorted claims and tasks, missing_for_seconds, and an explicit permanent-arm remedy. Re-arming the waiter or ending all owned work clears the episode, so a later regression can alert again. This is an operator warning: it neither releases work nor changes claim or blackboard authority.

The envelope builders and the message-type constants live in synapse_channel.core.protocol; the working agreement is in the repository's TEAM_PROTOCOL.md.

Handler exception boundary

Malformed JSON, non-object JSON, invalid routing fields, and handler-specific validation failures receive a typed error or refusal frame whenever the connection can still be addressed. The connection remains usable. A normal transport disconnect is also expected and is handled without escalating it as a hub failure. Expected persistence and wire failures are caught at their owning handler boundary, where the operation can be refused or rolled back without leaving a false success state.

Unexpected handler exceptions are different: the hub cannot know whether a custom or newly extended handler partially mutated state before it failed. It therefore does not broadly catch and continue after such an exception. The exception propagates to the WebSocket server, which closes the affected connection (normally with code 1011), while lifecycle cleanup always unregisters the socket in a finally block. A newly identified recoverable exception class must be handled locally by its owning handler and accompanied by evidence that the refusal or rollback leaves no partial state.

Governed identity-pin reclaim

The request carries pin_name, expected_key_id, a non-empty reason, and an optional boolean break_glass. The requesting socket is already bound to the envelope sender; the hub additionally requires that sender to have proved a TOFU pin or an operator-managed identity bundle and to hold the ACL permission identity-pin-reclaim on target kind agent for pin_name. This grant is always checked by the handler, even when the general ACL compatibility switch is off. A durable event journal is mandatory.

Without break_glass, the target must have no live socket and any opt-in name ownership lease must have lapsed under the hub's configured offline TTL. With break_glass: true, the same ACL-authorised, exact-key request may revoke the live socket and lease. The hub write-ahead records an approved audit event, compare-and-swap removes only a pin still matching expected_key_id, then records applied; a storage failure or race records not_applied and leaves no false success verdict. An applied action is also broadcast as a body-free system notice. Public key material and replacement key material never enter the audit or the wire request.

The result carries applied, pin_name, expected_key_id, break_glass, an actionable payload, and the applied audit_seq when successful. Reclaim only removes the old binding: the next valid registration proof may establish a new first-use pin. Because the verb is explicit operator control rather than an automatically emitted compatibility feature, an older hub simply refuses the unknown request; clients never send it during ordinary connect or messaging.

Directed delivery and the mailbox

A chat addressed to a target — one name, a project/* group glob, or a project/role a holder answers to — uses the compatibility broadcast flow by default, so each client filters for the messages meant for it. With --private-directed-messages (forced by --team-secure), the hub instead sends the frame only to recipients, their -rx sidecars, and identities holding the ACL observe grant. The durable journal and configured relay log still retain the message for audit and replay.

Immediate receipts. A chat sent with receipt_requested: true gets a private delivery_receipt back: delivered: true with the matched recipients when a live connection matched the target, or delivered: false when none did. A directed message that matched no live connection is a dead letter — durable in the journal and feed, but woken by nobody at send time. Journal-backed hubs also include a stable receipt_notification_id. A transport retry may repeat the same id; senders deduplicate on it rather than interpreting duplicate frames as new outcomes.

Reconnect replay (the mailbox). A client that missed directed messages while offline can ask for them on reconnect. On its registration heartbeat it sets:

  • mailbox: true — request a replay of the directed backlog.
  • since_seq — the last durable journal seq it has already processed; the hub advances that identity's receiver watermark and replays only chat after it. A missing or malformed value degrades to 0 (the whole retained window).
  • mailbox_for (optional) — the identity whose backlog to replay, when it differs from the connection name. A wake-listener connects under a receive-only -rx name but waits on its bare identity, so it names that identity here; absent or blank, the hub replays for the connection name. Roles are always read from the connection.

The hub re-sends each missed directed message as an ordinary chat frame marked replayed: true and stamped with its durable seq. A client dedups on seq, not msg_id — the per-hub msg_id counter resets on restart while seq never repeats. Broadcasts are never replayed, and a hub with no durable journal replays nothing.

Receiver watermark and pending count. A mailbox client sends ack for every live or replayed chat admitted by its mailbox_advance gate. The frame carries the durable seq and may carry mailbox_for so an identity-rx sidecar advances the bare identity, not its connection name. The hub validates that the stored chat was directed to that logical identity before advancing, and journals the monotonic cursor as mailbox_watermark. A registration since_seq advances the same cursor after the existing mailbox-identity authorisation check.

The additive who_snapshot.mailbox_pending field is a per-identity integer map when the hub has a durable journal, or JSON null when the projection is unavailable. A count is the matching directed chats after the receiver watermark; it is what synapse who, synapse status, and synapse doctor render as N undelivered messages pending for <identity>. This is deliberately a mailbox transport fact: it does not claim that a model read, understood, or acted on the message. The hub bounds this projection to 512 recently touched identities; that cache bound does not delete journal events. The default synapse who presentation independently shows the 20 largest positive counts plus complete totals, while --all-mailbox-pending expands the retained map. Older clients ignore the WHO field; older hubs ignore the additive ACK identity and keep their receipt-only ACK behavior.

Chat retry identity. Chat delivery is explicitly at least once. The hub does not consume idem_key for chat and does not suppress a retry. A sender that may retry across reconnects instead supplies an optional printable client_msg_id of at most 256 UTF-8 bytes. The hub echoes the normalized value on live delivery, bounded history, the durable chat row, mailbox replay, immediate / deferred receipt frames, and the durable receipt ledger. Receivers deduplicate on (sender, client_msg_id); msg_id and durable seq identify individual delivery attempts and therefore differ across retries. Missing or invalid client_msg_id keeps ordinary at-least-once behavior. The hub never treats this caller-chosen identity as authentication or authorization.

Durable ingress quotas. Optional per-principal sliding-window bounds cap how many chat events and serialized chat-frame bytes a server-derived quota principal may have accepted inside a window. The hub charges the connect-token fingerprint (or the open-host bucket), not the free-form sender name, so name rotation cannot multiply the budget. A refusal is an error frame (Durable ingress quota exceeded (events|bytes|oversized|principal-capacity).) and happens before bounded history or the durable journal grow; admitted chats still journal normally. --secure enables the default window when the operator left the flags disabled. The bounded principal table evicts only expired buckets; when every slot is active, new principals fail closed instead of resetting another principal's quota.

Consume-live immediate receipts. For a directed chat, the hub partitions socket-level matches using the same reaction-plus-waiter liveness policy exposed by WHO. At least one consume-live match yields delivered: true. If sockets match but every recipient is stale, the immediate receipt instead carries delivered: false, reason: "no_live_recipient", the complete matched_recipients and stale_recipients, and dead_lettered: true; no socket match uses reason: "no_online_recipient". These fields are additive and do not change the wire version. The chat stays durable and may still be queued to a stale socket as a best effort, but transport presence is never promoted to a positive delivery verdict. This is reachability/acceptance evidence, not proof that a model understood or acted on the body. A hub with stale-recipient tracking explicitly disabled keeps the compatibility behavior and treats its socket matches as live.

Deferred receipts. When a receipt_requested directed message dead-lettered, the hub remembers it in a bounded pending-receipt store keyed by its seq. When the recipient reconnects, drains the replayed message, and sends ack: {seq, mailbox_for?}, the hub re-checks that the logical mailbox identity is a genuine recipient of the original target and then sends the original sender a second delivery_receipt marked delivered: true, deferred: true. A journal-backed hub commits that transition and its stable sender-notification outbox row together. If the sender is offline, or the process dies after WebSocket acceptance but before durable acknowledgement, the hub retries the same receipt_notification_id after the sender next authenticates. This is at-least-once transport notification, not chat-mailbox replay and not proof that a model read or acted. A spoofed ack from a client the message was not addressed to neither fabricates a receipt nor drops the pending one. The ack verb arrived at wire version 2; a client emits it only when the hub advertises that version or newer.

Durable receipt aggregate and outbox. A hub with a SQLite journal atomically commits each receipt-requested directed chat with its requested-receipt aggregate, then commits each immediate, pending, deferred, or expired transition with the corresponding stable sender-notification outbox row. When the bounded pending window evicts its oldest entry, that expiry and the incoming pending transition share one transaction before the in-memory projection changes. The append-only audit events are: delivery_receipt_requested, delivery_receipt_immediate, delivery_receipt_deferred, and delivery_receipt_expired. On restart, unsettled immediate failures re-seed the bounded pending-receipt store, so a later mailbox ack can still journal the deferred verdict even if the original sender is offline. Operators can query the ledger with synapse event-query <db> "receipts <agent>". When supplied, client_msg_id is included in every receipt phase so a sender can correlate the durable final verdict with its original retry identity. A crash before an immediate verdict leaves the aggregate honestly at requested; it never invents a positive delivery. Outbox WebSocket acceptance settles only the notification row and never represents human or model consumption. Mailbox watermarks are separate mailbox_watermark events: losing the newest normal-durability watermark in a power failure can cause safe replay/recount, not loss of an unseen message body.

Release receipts

A successful release may carry closeout evidence. The hub echoes a machine-readable receipt object on release_granted with these fields:

  • task_id, owner, and released.
  • Repeated evidence lists: evidence, artifacts, known_failures, changed_files, generated_artifacts, and approvals.
  • Optional confidence and freshness_seconds.

When any evidence field is present, the hub also records a compact ledger_progress_posted assessment note for the same task, so synapse board shows the release closeout alongside the task plan. The hub records the submitted evidence; policy decisions about whether that evidence is sufficient remain outside the wire protocol.

Decoder hardening

Inbound hub and A2A JSON frames use loads_bounded() from synapse_channel.core.protocol. The helper scans raw text for array/object nesting before calling json.loads, so a malformed or deeply nested frame fails as a normal JSON decode error instead of recursing through the interpreter.

tools/fuzz_protocol_decode.py is the local decoder hardening evidence harness. Run PYTHONPATH=src python tools/fuzz_protocol_decode.py --smoke for the deterministic seed corpus, or install Atheris and run PYTHONPATH=src python tools/fuzz_protocol_decode.py for an open-ended fuzzing session. The read-only fuzz.yml workflow also runs weekly and on manual dispatch. It gives each Hypothesis property 1,000 generated examples against the actual loads_bounded() decoder and EventStore persistence path, including reopen, cursor-walk, and deletion invariants. A discovered counterexample must be promoted to a committed @example regression so it survives the ephemeral CI Hypothesis database.

This is not an external protocol-conformance certification; it is automated local property-based coverage for malformed bytes, malformed JSON, quoted bracket runs, valid nested JSON, depth-limit rejection, and persistence round-trips.