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 6), 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.

Version 3 adds session-bound delivery requests, explicit recipient stages, cancellation and durable outcome evidence. The version 2 ack remains a transport-only mailbox receipt. The delivery compatibility decision records the reviewed migration and mixed-version boundaries.

Version 4 adds the signed, scoped local attachment verbs. A client sends these only after the hub advertises version 4; version-three delivery sessions remain valid on a version-four connection.

Version 5 adds hub-to-hub message forwarding: a chat or delivery addressed to PROJECT/seat@HUB_ID is forwarded to that configured peer hub. The two new frames travel only between hubs, so agent-facing frames keep their earlier meaning; see Cross-hub message forwarding.

Version 6 adds recipient-granted private cross-hub attachment reads. A peer registers at version six and negotiates the source welcome before sending attachment_peer_request; earlier versions retain their existing APIs. See recipient-granted reads.

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); version-three delivery_request, delivery_status_request, delivery_cancel, delivery_boundary, delivery_ack and delivery_outcome.
  • 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.
  • Scoped attachments (v4): attachment_begin, attachment_chunk, attachment_commit, attachment_abort, attachment_info, attachment_read, attachment_ref, attachment_gc; see Scoped attachments. Each returns a private attachment_result after bound identity, signature, role and ACL checks.
  • Queries: state_request, who_request, history_request, resume_request. A who_request carrying hub asks that configured message peer for its roster (wire version 5).
  • 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. identity_enroll adds or rotates one identity key on a hub with --identity-enrollments, after the proven requester, identity-enroll ACL, identity-enroller role, namespace allow-list, rate and durable-audit gates pass. identity_revoke revokes one enrolled key behind the same gates.
  • Fleet planning input: entitlement_advert records one owner's redacted pool advertisement as an audit-only journal row after the proven-sender, entitlement-advertise ACL and durable-journal gates. It is never broadcast.
  • Shared-pool reservations (F02): spend_request is a peer hub's request to a pool owner hub. It carries spend_action (reserve, settle or query) and a spend document.
  • The owner answers only a peer its multi-hub serving policy authorises, and only with --spend-ledger.
  • It decides in its owner-only ledger, never in the replicated journal.
  • 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.

Exact history selection

history_request accepts optional history_client_msg_id and history_target string selectors. The hub applies exact matches before limit; an empty or non-string selector returns no matches, never the full history. Ordinary recall ACL checks still apply. For example, setup verification requests its canary's client message ID and recipient with limit: 1, without transferring unrelated history or increasing the client's receive budget. The response remains a history_snapshot with a history list. A missing or evicted message produces an empty list and cannot prove replay. Older hubs may ignore these additive selectors; consumers must validate the returned message identity and recipient.

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.

If journalling a ledger_task, ledger_task_update, or ledger_progress write fails, the hub sends only the requesting connection a private error: Task '<task_id>' was not journalled; mutation rolled back. or Progress note was not journalled; mutation rolled back. Storage diagnostics stay in server logs. No task/progress candidate, event, or durable idempotency result is published. The connection remains usable; once storage recovers, the caller can retry the same idem_key. An accepted keyed retry is committed once and subsequent replays return its original result privately. Clients and dashboards must keep the prior board state when receiving this refusal.

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.

Malformed parent documents are refused before a task or durable operation is created. Only deliberately authored validation reasons reach the requesting connection, prefixed with Malformed frame:. An unexpected parser fault instead returns the private error sentence Causal parent validation failed; task was not changed.; its traceback stays in the hub log. No refusal is broadcast or changes the board. The connection remains usable, and a corrected request may reuse the same idem_key. Clients and dashboards retain the prior task until an accepted write arrives.

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. identity_enroll_result and identity_revoke_result are the verdicts for identity_enroll and identity_revoke. entitlement_advert_result is the private verdict for entitlement_advert. spend_result is the private answer to spend_request. Every refusal is the same not-admitted shape, and a query returns a stored answer without creating anything.

A claim_granted notification can be a broadcast about another agent's task. Before treating it as confirmation of a claim, match both task_id and owner to the request and require the hub sender. A dependent wait_request sent after an unrelated notification can arrive before the intended claim; the hub then correctly returns wait_denied because that task is not yet held.

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.

Persistence refusals carry applied: false and the authored payload could not persist the reclaimed pin table. Enrolment, rotation and revocation use could not persist the enrolment store in their private typed results. The matching not_applied audit uses that same text and links to approved_seq; the result's audit_seq refers to the approval, not an applied event. Exception text and tracebacks remain in server logs. Original authority and live holders survive, no applied notice is broadcast, and the connection remains usable. Repair storage before sending a fresh governed request; repeating an applied change does not apply it again. These verbs have no idempotency-key replay contract. Dashboard receipts preserve each approval, refusal and application as distinct events.

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 recipients when a consume-live recipient or its waiter completed a write, 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 at least once, and the hub does not consume idem_key for chat. A sender that may retry across reconnects 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.

Once a copy from a directly connected sender reaches a live recipient (or a peer hub's forward outbox), the hub remembers its (sender, client_msg_id) with a digest of the chat's content. That memory lasts up to 24 hours and 4096 pairs and lives only in the hub process. - Same pair, same content: a retry is not stored, journalled or delivered again. The sender receives a system frame with duplicate: true, client_msg_id, and the first copy's msg_id, plus seq, channel or forward_id when the first copy had one. The first copy's delivery receipt, if requested, stands. - Same pair, different content: the chat is refused with an error. - A chat that reached nobody: it is not remembered, so resending it is a redelivery attempt and is routed again. - After a restart, outside the retention, or by other paths: copies are still possible, so receivers should keep deduplicating on (sender, client_msg_id). - Invalid or missing client_msg_id: plain at-least-once behavior. - Chats a peer hub forwards are deduplicated by their forward_id instead. - 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. A completed write to 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.

Recipient transport failure

Outbound writes have a five-second drain deadline and a one-second graceful close attempt before a stalled transport is aborted (1013, outbound delivery timeout). For directed chat, delivered: true requires a completed write to a consume-live recipient or its waiter. Sender and observer echoes do not count as recipients. When no such write completes, reason: "recipient_transport_unavailable" accompanies a negative receipt; matched_recipients and stale_recipients retain the original routing evidence. The chat remains durable and retryable with the same client_msg_id. A completed transport write proves neither application processing nor task execution. Clients must retain retry deduplication because bytes may arrive before a drain times out.

Private-channel fan-out follows the same completed-write rule and sends to members concurrently. A positive channel receipt lists only successful members. If every matched member write fails, it is negative with recipient_transport_unavailable, and a retry with the same client message ID remains eligible.

Session-bound delivery (wire version 3)

As of 2026-09-23, this section describes the 0.99.27 source candidate. The published 0.99.26 package still speaks wire version 2; do not downgrade a hub with unresolved v3 work without the rollback procedure below.

A durable hub with an explicit stable hub_id admits version-three delivery. Registering a delivery-capable recipient on a non-durable hub closes that whole connection with code 4020, including ordinary chat on that connection; use a separate legacy chat connection or deploy a durable hub. On an unauthenticated hub, anyone able to claim a recipient name can supersede its open work. That denial-of-service exposure follows the existing name-trust model; use hub authentication and identity binding for protected delivery. The receiver registers delivery_session_token (a 64-character random hex value) and a map of delivery_capabilities on its first authenticated heartbeat. The hub publishes only a SHA-256 incarnation digest. A version-three who_snapshot lists live delivery_sessions with incarnation, capabilities (native or emulated) and hub ID. Older peers do not receive this field. The recipient receives delivery_session after registration.

The sender submits delivery_request with an exact target and incarnation, request_id, idempotency_key, mode, optional ordered allowed_fallbacks, task_id, body, and an absolute Unix deadline. Modes are interrupt, steer, follow_up, and next_turn. The body is limited to 8,192 UTF-8 bytes; ids to 128 bytes, and fallbacks to three. The hub selects only an advertised capability and reports selected_mode and quality. Unpaired Unicode surrogates in bounded delivery text, mutation IDs or evidence fields receive invalid_shape; an invalid request ID is not echoed in the refusal. Interrupt and steer also require an authenticated hub, an explicit DELIVERY_CONTROL ACL grant, and a live exact-target claim. A request with no safe match receives delivery_refused with a stable reason_code; it never becomes chat. The hub caps unfinished requests at 128 per recipient incarnation and 16 per sender within that recipient incarnation. Both limits return recipient_queue_full before appending another request. The SynapseAgent.request_delivery() API refuses an old hub before sending a v3 frame. A v2 peer retains the mailbox ack contract.

The local recipient bridge also queues at most 128 offers. A nonconforming hub that sends beyond that cap can hold its socket reader while it tries to report queue_full; the reader then cannot consume the status reply. The hub admission cap prevents that state for a conforming recipient incarnation. Operators must restore a conforming hub or restart the bridge if this failure is observed.

Admission commits delivery_intent_accepted, delivery_intent_queued, the aggregate, and a stable delivery_offer notification before socket delivery. Retries with the same sender, request ID, idempotency key and content return the original operation; changed content returns id_conflict. The recipient's delivery_boundary, delivery_ack, and delivery_outcome frames must name the operation, request, task, and stable mutation ID. completed and failed outcomes also require an executor reference and outcome code. The hub commits each transition and notification atomically. The status view reports receiver reachability, active session, boundary delivery, explicit ACK, cancellation request, and task completion as separate facts. Socket delivery or ACK alone does not prove execution.

delivery_cancel records a sender request; it is not a terminal executor decision. Repeated cancellation of the same open intent returns its current status without appending another event or notifying the recipient again. A completion racing the original request retains both facts. A deadline sweep commits expired; replacement of the recipient's process token commits superseded for unfinished older-incarnation work. Reconnect with the same token may replay a queued offer with the same notification_id; a fresh token cannot inherit it. Terminal work retires undelivered offers to the old recipient while retaining their audit rows. A durable hub refuses to open a delivery journal under a changed stable hub_id; restore the original identity or use the explicit legacy ownership recovery procedure. Storage profile 4 binds the receiving hub separately from the immutable request origin; forwarded storage-profile-3 history needs offline binding before startup. Sender notifications missed while offline replay by stable ID. The hub promises at-least-once notification and recipient deduplication, not exactly-once external provider effects. DeliveryParticipantBridge executes follow-up and next-turn offers in an ordered queue through a Participant and keeps a local duplicate ledger; it does not advertise interrupt or steer. If a terminal hub status races a stage report, the bridge stops that queued turn before invoking the provider rather than retrying the old stage forever.

Version 0.99.27 has no delivery-journal compaction. Terminal delivery rows, event history and audit notifications accumulate with use, so durable storage and restart replay work grow with retained history. Monitor the journal and plan capacity for that growth; deleting rows by hand would break replay and idempotency evidence. An operator compaction procedure needs a separately reviewed archive and hash-chain checkpoint before it can be used.

The Python entrypoint is SynapseAgent.request_who() followed by SynapseAgent.request_delivery(...) using the exact incarnation from the returned who_snapshot. The same client exposes request_delivery_status, cancel_delivery, and report_delivery_stage; their replies arrive through the normal message callback. A provider process can compose DeliveryParticipantBridge(agent, participant, ledger_path=...), set the agent's callback to bridge.on_message, call bridge.start(), and supervise agent.connect(). The bridge checks provider health before starting and waits for the hub's boundary and ACK status before taking a model turn. A supervisor must reconnect the same agent instance after a socket loss to preserve the in-memory process token; creating a new instance starts a new incarnation and supersedes unfinished old work. Configure the provider's own edit, shell and network permissions before advertising a native capability. The bridge does not grant tool permissions or override provider approval prompts.

Cross-hub message forwarding (wire version 5)

A hub configured with message peers delivers chats and version-three delivery intents to seats on those peers. The agent writes the target as PROJECT/seat@HUB_ID; everything else about the frame is unchanged. HUB_ID is 1–64 characters of letters, digits, ., _ and -, starting with a letter or digit. Every name containing @ is reserved: a local client that registers one receives name_conflict and the connection closes with code 4009, so a local seat can never pose as a forwarded sender.

Configuration. The origin hub names each peer with synapse hub --message-peer HUB_ID=URI (repeatable), plus --message-peer-token/--message-peer-token-file, a --message-peer-pin per wss:// peer, and the multi-hub client certificate for mutual TLS. The receiving hub accepts a forward only from a peer that holds a grant in its multi-hub serving policy and presents its pinned client certificate on the live connection. The federation peering lists the local project namespaces that peer may address; the target seat's namespace must be one of them. A hub without a serving policy accepts no forward.

Chat. A cross-hub chat names exactly one seat: a comma list, an audience or a glob (*, ?, [) is refused locally, as is a hub that is not a configured peer. A chat sent to a channel follows the channel rules and is never forwarded. The origin hub keeps and journals the chat like a local one, writes it to a durable outbox, and attempts the forward immediately. An unanswered forward is retried after 1, 2, 4 … seconds (at most 300) until the peer answers or the forward expires after --message-forward-ttl seconds (default 86400). Delivery is at least once: a retry carries the same forward_id, and the peer answers a repeat with duplicate instead of queueing a second copy. A reused forward_id with different content is refused (forward_id_conflict). Only accepted answers are remembered, so a forward refused for a transient reason, such as the peer's ingress quota, succeeds on a later retry.

The peer routes the chat through its normal router — mailbox, private routing, dead letters and its own quota for the forwarding connection. Its recipient sees sender as seat@ORIGIN_HUB, taken from the authenticated connection and never from the frame, together with forwarded_from and forward_id.

A sender that set receipt_requested receives a delivery_receipt with forward_id, forwarded_to and forward_state (pending, accepted, duplicate, refused or expired). recipients are hub-qualified, and delivered is true only when the peer reported a consume-live recipient. A pending receipt has reason forward_pending and deferred true. When the forward settles later, the sender receives a second receipt at once if online, or on its next registration. Settlements are reported with reason forward_refused or forward_expired. A peer older than version 5 answers the frame with an error, which settles the forward as refused (peer_rejected) without further retries. A receipt reports transport facts only. It never means the recipient acted on the message.

Delivery intents. A delivery_request whose target is PROJECT/seat@HUB_ID is forwarded synchronously. The peer admits it against its own recipient session, exactly as for a local requester named seat@ORIGIN_HUB. A forwarded requester is never granted interrupt or steer; such a mode is refused with unauthorised_requester. The relayed delivery_status carries remote_hub. Later delivery_status_request and delivery_cancel frames for that operation key are routed to the same peer, and only the requesting seat may send them. A hub that has admitted a forwarded intent uses the receiving hub's durable identity for deadline and session decisions, while retaining the authenticated origin in its request. New storage profile 4 and explicitly bound legacy journals cannot be opened by delivery-aware older releases, including 0.99.36. See journal recovery and rollback. Back up the event store before adoption. A forward that cannot complete is answered with delivery_refused whose reason_code is one of the following: unknown_hub, peer_unreachable, peer_rejected, peer_invalid_answer, invalid_target, invalid_shape or the peer's own refusal code.

Roster. A who_request with hub returns that peer's who_snapshot with remote_hub. Its seats are named seat@HUB_ID and limited to namespaces the peer lets this hub address. An unreachable or refusing peer yields an error frame instead. On the command line this is synapse who --hub HUB_ID.

Hub-to-hub frames. These frames are exchanged only between hubs; a receiving hub serves them only to a granted peer. Each forward opens one connection, registers under the origin hub's id, sends one multihub_message_forward, awaits one multihub_message_result for up to ten seconds, and closes.

Frame Fields
multihub_message_forward forward_id (at most 128 bytes), kind (chat, delivery_request, delivery_status, delivery_cancel or who), sender_seat, target_seat (required for chat and delivery_request, absent otherwise; never hub-qualified), body (a JSON object of at most 262144 bytes)
multihub_message_result forward_id, disposition (accepted, duplicate or refused), answering_hub, reason_code (lower-case, refusals only), detail (at most 512 bytes), result (object)

A receiving hub refuses with one of these codes: invalid_origin, peer_not_authorised, namespace_not_granted, invalid_target, invalid_shape, chat_refused, unknown_request, journal_recovery_required, forward_id_conflict or a delivery refusal code. It answers a frame it cannot decode with a plain error. The receiving hub applies the one-seat rule itself, so a comma list or glob is refused (invalid_target) even if a modified peer sends it. While startup replay has quarantined corrupt journal rows, it refuses forwarded chats, delivery intents and cancellations (journal_recovery_required), as it refuses local mutations. Roster and status reads stay available. The origin also refuses a peer's operation_key that is not 64 lower-case hex digits or that is already routed on the origin hub (peer_invalid_answer). Otherwise a peer could redirect another delivery's follow-ups.

The origin hub reports its unanswered forwards on /metrics (synapse_message_forward_pending and synapse_message_forward_oldest_pending_seconds) and gives a per-peer breakdown in the message_forward field of /health. See observability.

Forwarding is one hop between configured peers. There is no relay through a third hub, no cross-hub broadcast or channel, and no automatic peer discovery.

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.

Keyed release verdicts also echo release_operation_id (the request's idem_key) and request_digest (SHA-256 of canonical semantic JSON, excluding timestamp, client_timestamp, auth, and signature). Callers must match the hub sender, owner, task, key, digest and receipt before accepting a grant. Release ACL and per-message-authentication errors also echo the operation key and task so an unrelated asynchronous error cannot masquerade as its refusal.

For exact read-only recovery, state_request may carry a bounded request_id and release_confirmation: {task_id, operation_id, request_digest}. A client can use this nonmutating query before sending a release: an unknown projection must echo all three intent fields, while confirmed must bind the exact owner, key, digest and valid released receipt. A generic state snapshot, wrong request ID, foreign sender/target/hub or malformed projection cannot establish support. This extension does not change the wire version; older hubs ignore it. The key is 1–128 characters with no NUL; the digest is 64 lowercase hexadecimal digits. The authenticated sender's release namespace is the only operation lookup. The private state_snapshot echoes the valid request id and contains only release_confirmation, without the ordinary full snapshot.

A confirmed projection carries status: "confirmed", task, owner, operation identity, matching receipt, first_event_seq and commit_seq. It verifies the durable request digest, response hash, release event and matching idempotency commit witness. Unknown projections have status: "unknown" and no receipt; malformed input, missing/non-durable data and damaged storage all remain unknown. No release, renewal, replay or journal write happens on this read. A confirmed historical release says nothing about a newer lease for the same task.

These optional fields do not change the message vocabulary or wire version. Legacy hubs can ignore the request extension and return an ordinary snapshot; that is not confirmation. A fresh manual release refuses before sending with exit 1 when the probe cannot establish support. After a mutation was sent, conservative exit 3 and exact operator recovery remain as documented in the CLI reference.

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.

Cross-hub attachment reads (wire version 6)

attachment_peer_request names action: "info" or "read", scope and digest; reads additionally name an integer offset. The source checks live peer identity and namespace trust, then its exact recipient grant before storage. Private attachment_peer_result contains ok: true and metadata, or a bounded base64 chunk with its scope, digest, offset and EOF. Handler refusals contain only ok: false and error: "attachment unavailable" beyond the normal envelope. Connection authentication failures retain their existing types. No content is broadcast or replicated into the event log. Bounded private owner audit decisions remain in the separate attachment ledger. Peer transports require TLS outside loopback and verify the source certificate by CA/hostname or an explicit live pin before registration. See the complete attachment contract.

For correlated read-only claim confirmation, state_request may carry an opaque request_id string of 1–128 characters. The hub echoes only that bounded string on its private state_snapshot; missing/invalid ids remain absent. This additive field does not change the wire version. Clients must match the requesting identity and exact id before treating that snapshot as confirmation. See claim outcomes and recovery.

Durable inbox query

history_request optionally carries request_id and an inbox_query object. This additive query has its own version: 1; ordinary history selectors and the wire version remain unchanged:

{"type":"history_request","sender":"PROJ/reader","target":"System",
 "request_id":"reader-unique-request",
 "inbox_query":{"version":1,"identity":"PROJ/reader","hub_id":"",
                "since_seq":0,"limit":50}}

An initial cursor is zero with an empty hub binding. Subsequent requests bind the returned hub_id and use cursor as since_seq. Integers exclude booleans; since_seq is within SQLite's nonnegative signed 64-bit range and limit is 1–100. The requester must be the exact identity or one of its recognized receive-only waiter sidecars. Identity admission applies. ACL enforcement requires mailbox on agent:<identity>; this does not grant recall on global history.

The private history_snapshot echoes a request ID of 1–128 characters and contains inbox_page: version, available, identity, hub_id, cursor, messages and has_more. Messages carry durable journal seq. Ascending reads scan at most 1,000 chat rows, with a 7 MiB encoded-message budget. The cursor advances past inspected foreign rows and returned messages, without consuming the next matching message. has_more may be true with no messages.

Exact, comma-separated, project, wildcard and admitted-role recipients are matched. Own chat and channel-tagged chat are excluded. Invalid queries, foreign identities, changed hubs, cursors beyond retained history, absent journals and oversized first messages return available: false with fixed refusals. Clients must require this explicit schema: a legacy ordinary history reply is not proof of an empty inbox. Retention bounds recovery; reading does not advance waiter ACKs or prove model processing.