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 durableseq, optionally namingmailbox_for; see Directed delivery and the mailbox); version-threedelivery_request,delivery_status_request,delivery_cancel,delivery_boundary,delivery_ackanddelivery_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 privateattachment_resultafter bound identity, signature, role and ACL checks. - Queries:
state_request,who_request,history_request,resume_request. Awho_requestcarryinghubasks that configured message peer for its roster (wire version 5). - Governed operator recovery:
identity_pin_reclaimremoves 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_enrolladds or rotates one identity key on a hub with--identity-enrollments, after the proven requester,identity-enrollACL,identity-enrollerrole, namespace allow-list, rate and durable-audit gates pass.identity_revokerevokes one enrolled key behind the same gates. - Fleet planning input:
entitlement_advertrecords one owner's redacted pool advertisement as an audit-only journal row after the proven-sender,entitlement-advertiseACL and durable-journal gates. It is never broadcast. - Shared-pool reservations (F02):
spend_requestis a peer hub's request to a pool owner hub. It carriesspend_action(reserve,settleorquery) and aspenddocument. - 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_denialadmits one content-minimized native file-guard refusal;guard_denial_recordedacknowledges 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_schemaandoutput_schema: JSON-object mappings, usually JSON Schema fragments, describing accepted input and produced output.preconditionsandpostconditions: 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_resultis the private applied/refused verdict for anidentity_pin_reclaimrequest.identity_enroll_resultandidentity_revoke_resultare the verdicts foridentity_enrollandidentity_revoke.entitlement_advert_resultis the private verdict forentitlement_advert.spend_resultis the private answer tospend_request. Every refusal is the samenot-admittedshape, and aqueryreturns 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 journalseqit has already processed; the hub advances that identity's receiver watermark and replays only chat after it. A missing or malformed value degrades to0(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-rxname 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, andreleased.- Repeated evidence lists:
evidence,artifacts,known_failures,changed_files,generated_artifacts, andapprovals. - Optional
confidenceandfreshness_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.