v0.98.0
Added
-
Hub pinning indicator — the hub now reports a
config_epochalongside its
version, a short deterministic fingerprint of its configuration posture (the
scalar limits and the armed/disarmed state of each optional subsystem: auth,
ACL, per-message auth, metrics, multi-hub, federation). It appears in the hub's
/healthresponse, in thewhosnapshot, and in the dashboard's
/snapshot.json(ashub_versionandconfig_epoch), so a cockpit can badge
which hub build and configuration it is watching and notice a deploy or a config
drift. Honest scope: it fingerprints posture, not secrets — object-valued
settings enter only as a presence marker, so rotating a key or editing an ACL
rule within the same posture does not change it. A hub built without a grouped
config (an ad-hocSynapseHub()) reports an emptyconfig_epoch. -
/waits.jsondashboard store feed (with--feeds-db) — the pending
coordination gates reconstructed from the durable plan: each non-terminal task
blocked on a dependency that has not reached a terminal status, withwhois
waiting (the task's suggested owner, or whoever declared it),on_what
dependency ids block it, andsincewhen it was declared, plus await_count.
The "what is the fleet stuck behind" panel. Store-derived and deterministic
(dependency satisfaction judged from the log's own recorded task statuses),
available with the hub down; 404 without--feeds-db, 503 on an unreadable
store. Transient socket waiters are not journalled and are omitted — this is the
coordination gates the plan can prove, not who holds a socket open. -
/sessions.jsondashboard store feed (with--feeds-db) — the opt-in
session_metrictelemetry the fleet left in the durable log, in the same JSON
synapse participants costsrenders: per-session token counts, cost, latency,
and error/abstention rates, plustotalsaggregated across sessions. Every
record carries theseqof the snapshot it was read from and the coordination
task_idfrom the note body, so a cockpit joins a session's cost straight to
its causal cone (via/causality.json) — "this session spent N tokens on task
T42", not merely "on session S". Same posture as the other store feeds:
store-derived and deterministic (available with the hub down), 404 without
--feeds-db, 503 on an unreadable store; a log with no session notes reports
emptysessionsand zeroedtotals, never a fabricated cost. -
Operator write-path for the dashboard (opt-in) —
synapse dashboard --operator
arms three write routes so the cockpit can act on the fleet rather than only
observe it:POST /message({"to","text"}) relays a chat message,POST /task
({"id","title","depends_on"?}) declares a board task, andPOST /task/update
({"id","status"?,"note"?}) changes a task's status and/or appends a progress
note. Off by default: without the flag every route is a 404, indistinguishable
from an unknown path, and the dashboard stays a read-only observer. When armed, a
write still requires the dashboard bearer token, is rate-limited, and is sent
under an explicitoperator:<name>identity that never impersonates an agent.
The relay reimplements neither authorisation nor auditing — the hub ACL-checks
the relayed frame and records it in the durable log, so every operator action is
authorised at the hub and appears in replay,/state-at, and the signal stream.
Responds200when delivered, dead-lettered, or applied,403on ACL refusal,
409when the blackboard refuses a task on its own terms, and503when the hub
is unreachable. -
CycloneDX SBOM (
synapse-channel-<tag>-sbom.cdx.json) is generated from the
built wheel's dependency closure and attached to every GitHub Release alongside
the distributions, so the published software bill of materials is a first-class
release artifact. The generator (cyclonedx-bom) is hash-locked in a dedicated
requirements-sbom.txt. -
allconvenience extra bundling every runtime feature library (cryptography,
WASM, OTel, MCP) —pip install synapse-channel[all]for a full-feature install
without naming each extra. A packaging drift guard keepsallexactly the union
of the feature extras, keeps the runtime floor a single dependency, and imports
every feature-consuming module to prove the base import surface never
hard-requires an optional library.
Changed
- Removed the hub's
register/unregister/_authenticate_or_closemethods —
thin wrappers over theHubConnectioncollaborator that no live path or test
reached once the socket lifecycle moved behind thehandlerentry point. The
collaborator owns these steps and is tested on them directly; the hub keeps only
thehandler,_send_welcome, and_install_signal_handlersdelegators, each
with its own hub call site (serve, the withheld-welcome path, and shutdown
wiring). No behaviour change — a redundant indirection is gone.core/hub.py
drops from 1003 to 978 lines. - Extracted the hub's durable-state seeding into
core/hub_state_seed.py
(seed_hub_state): the decision to replay the event log — resuming live leases,
chat history, the shared blackboard, and the ledger-guard seed (the message-id
high-water mark, per-actor finding counts, and the idempotency cache) — or build an
empty registry, together with the one-off compaction hint a hub emits when opened on
an oversized log, now lives in one pure function returning aSeededHubStatethe
constructor binds, instead of a ~50-line branch inlined in__init__. It holds no
hub reference, so the resume-versus-fresh behaviour is testable without a live hub.
No behaviour change — a restart resumes exactly as before and the compaction hint
fires on the same threshold. Fourth slice of the resumed hub decomposition, taking
core/hub.pyfrom 1037 to 1003 lines (1294 at the start of the arc, with four
single-responsibility collaborators peeled). 100% line+branch on the new module. - Extracted the hub's frame-authorisation gates into
core/hub_frame_gates.py
(HubFrameGates): verifying required per-message authentication (an HMAC frame
signature or an Ed25519 signed-event signature), authorising a mutating frame
against the ACL, and routing a claim by namespace ownership — granting locally,
forwarding to the owning peer hub and relaying its verdict, or refusing fail-closed
with the owner named — now live in one class the hub holds, with
_verify_per_message_auth/_authorise_acl/_authorise_claim_ownershipleft as
thin delegating wrappers (thehandle_messagepipeline keeps one entry point per
gate) and the internal_observed_asserting_hubs/_forward_remote_claimfolded
into the collaborator. Routing itself stays on the hub, since a handler is invoked
with the hub as its first argument; the gates take the hub's per-socket send and
system-message factory as injected callbacks and capture their policy inputs at
construction, so the collaborator carries no back-reference to the hub. No behaviour
change — the auth verdicts, the ACL denials, and the claim grant/forward/refuse
decisions are identical. Third slice of the resumed hub decomposition, taking
core/hub.pyfrom 1202 to 1037 lines. 100% line+branch on the new module. - Extracted the hub's socket-connection lifecycle into
core/hub_connection.py
(HubConnection): admitting a socket against the capacity, per-host, and
unauthenticated-burst ceilings; welcoming it (on connect for an open hub, or only
after the first frame authenticates on a secured one); reading the authenticated
first frame under the auth deadline; pumping later frames into the routing
pipeline; releasing the agent name and broadcasting the departure on disconnect;
and theSIGTERM/SIGINTgraceful-shutdown wiring — now live in one class the
hub holds, withregister/unregister/handler/_send_welcome/
_authenticate_or_close/_install_signal_handlersleft as thin delegating
wrappers, soserveand every test keep one entry point. Frame routing itself
stays on the hub and is handed in as an injected callback, so the collaborator
carries no back-reference to the hub;_process_requestalso stays on the hub as
the HTTP-endpoint renderer. No behaviour change — connection admission, the
withheld-welcome timing on a secured hub, and disconnect cleanup are identical.
Second slice of the resumed hub decomposition, takingcore/hub.pyfrom 1258 to
1202 lines. 100% line+branch on the new module. - Extracted the hub's pre-route ingress guards into
core/hub_ingress.py
(HubIngress): authenticating a socket's first frame against the shared-secret
token, binding the claimed sender name (with optional takeover), keying the remote
host for per-host rate limiting, closing a socket, and refusing — or, when
overridden, warning about — an exposed bind now live in one class the hub holds,
with_authorise/_resolve_sender/_exposure_problems/_guard_exposure
left as thin delegating wrappers and_close_socket/_remote_hostkept as
class-callable staticmethods (the handler call surface is unchanged). It reads the
live socket registry and takes the hub's per-socket send and system-message factory
as injected callbacks, so it carries no back-reference to the hub. No behaviour
change; the token gate, name resolution, and exposure refusal are identical. First
slice of the resumed hub decomposition, takingcore/hub.pyfrom 1294 to 1258
lines. 100% line+branch on the new module. synapse hub --paranoidis now the full production secure preset: besides the
token, durable log, and per-message authentication it already required, it now
also requires ACL enforcement (--require-aclwith an--acl-policy) and native
WSS (--tls-certfile/--tls-keyfile), and still disables metrics query tokens
and the insecure off-loopback override. A paranoid start without ACL or TLS now
fails closed with a specific message. The missing-hooks report drops ACL and
signed events (now enforced) and names what genuinely remains — mutual-TLS
client-certificate verification and cryptographic per-agent identity.
Fixed
- A dashboard store feed (
/state-at.json,/merkle-proof.json,/events.json,
/causality.json) crashed with an unhandledOverflowErrorwhen a?seq=or
?limit=query carried an integer beyond SQLite's signed 64-bit range — an
arbitrarily large value parsed as an unbounded Python int, then overflowed
inside the store query. The feeds now bound the parsed integer and answer400
instead of a500. Found by the new query-feed fuzz test.
Tests
- Fuzz coverage for the dashboard store-feed query parsers: hostile
?seq=,
?task=,?direction=, and?limit=values (non-numeric, negative, huge,
duplicated, percent-encoded, multi-kilobyte) are thrown at every feed and the
handler must answer a deliberate status (200/400/404/503), never an
unhandled500. - Property-based coverage (Hypothesis) for the coordination invariants a
correctness bug would break silently: claim-scope overlap is symmetric and
agrees with per-path overlap (no file collision slips a non-conflicting scope),
a whole-worktree claim conflicts with any other, the idempotency cache replays
the most recent response for a key and never exceeds its bound, and a task never
takes a forbidden lifecycle transition — least of all out of a terminal state.
Documentation
SECURITY.mdgains a deployment-profile matrix (local-dev, single-user
workstation, team LAN, internet-exposed) mapping each profile to its required
controls, plus a capability→extra map, and its paranoid-mode summary is updated
for the now-required ACL and TLS.CONTRIBUTING.mdgains a definition of done
(changelog fragment, backward-compat statement, threat-model delta) and a
core/*hot-path ownership note.
Security
- Every dashboard and cockpit HTTP response now carries browser-hardening headers:
X-Content-Type-Options: nosniff,Referrer-Policy: no-referrer,
X-Frame-Options: DENY, and a same-originContent-Security-Policy
(frame-ancestors 'none',base-uri 'none',object-src 'none'; inline
script/style retained for the server-rendered pages). The dashboard is
self-contained, so the policy blocks injected remote resources at no cost. - The metrics query-string token (
--metrics-query-token-ok) is now loopback-only.
Binding a non-loopback host with it set is refused withInsecureBindError(like
the other exposure guards, downgradable with--insecure-off-loopback), because a
?token=value leaks into proxy access logs, browser history, and shell history.
On a loopback bind it remains a legitimate local debug aid; off loopback the token
belongs in theAuthorizationheader.