Appearance
Signed SSE Event Bus
This document is the authoritative bounded-context reference for the Signed SSE Event Bus that ships under internal/mesh/sse and exposes through GET /v1/nodes/{id}/events. It covers the ubiquitous language, the envelope schema and JetStream subject layout, the publish pipeline, the Last-Event-ID semantics, the in-memory nonce set, the threat model, the multi-replica handoff, and the operator runbook.
The bus is the plexsphere → plexd push channel: per-node long-lived SSE streams that carry signed envelopes with nonce/timestamp replay protection and a Last-Event-ID replay path on reconnect. Anything outside that surface — the underlying signer (internal/signing), the Postgres outbox, the JetStream broker, the audit sink — is a collaborator the bus orchestrates, not a concern of this document. This document is the implementation-side reference for the README's Signed Event Bus specification; the README pins the wire-level event-type catalogue and this document pins the in-process pipeline that emits it.
Status — a minimal slice is live behind an opt-in gate
The Signed SSE Event Bus was descoped from the first production release, and a minimal signed slice has since been partially un-descoped. The foundational primitives and the transport surface at GET /v1/nodes/{id}/events exist, and when the operator sets the opt-in NATS URL the composition root threads the full producer → relay → consumer pipeline so the surface streams signed envelopes; otherwise the endpoint stays on its fail-closed 501 stub. The push channel is an optimisation only — every dispatch converges through the reconciliation-pull baseline (GET /v1/nodes/{id}/state) with the bus switched off, so the stream never becomes a correctness dependency. See the un-descope decision for the rationale and the remaining deferred workstream.
Opt-in gate and the conditional 501
GET /v1/nodes/{id}/events answers 501 with code: signed_event_bus_not_provisioned only when the bus is not configured — the 501 is conditional, not unconditional. The composition root in cmd/plexsphere/sse_factory_prod.go reads PLEXSPHERE_SSE_NATS_URL:
- Unset (the default posture):
BuildProductionSSEFactoryreturns a bundle that constructs only the in-memoryNonceStore. WithEventStreamandSignatureVerifiernil, the handler mount gate inevents_dispatch.gokeeps the surface on its501stub — the unchanged posture for every deployment that has not opted in. - Set to a NATS URL:
buildFullSSEWiringdials JetStream, ensures thePLEXSPHERE_NODE_EVENTSstream, builds the signer-backed per-Domain resolver andEnvelopeVerifier, the publisher, the relay over the sqlc-backed cursor store, and the consumer-sideEventStreamAdapter, then populates theEventStream,SignatureVerifier,Publisher, and the relay + rotation run hooks. With the full security cone (EventStream,NonceStore,SignatureVerifier,RelationChecker,NodeRepo) wired, the surface streams signed envelopes instead of the501.
The mount gate is all-or-nothing: a nil on ANY of the five load-bearing ports fails closed with the 501 rather than admitting traffic past a gate that is not in place.
The minimal five-event wire-type set
The relay fans out a closed five-member wire-type set in this slice, pinned by sseMinimalWireTypes in cmd/plexsphere/sse_factory_prod.go and passed as RelayConfig.AllowedWireTypes:
node_state_updatedpolicy_updatedbridge_config_updatedaction_requestsession_setup
A row whose source outbox event_type maps (via the publisher's wireTypeFor dispatch table) to a wire type outside this set is skipped-and-advanced: the relay never publishes it, but it advances the cursor past the row (bumping sse_relay_skip_total{reason=wire_type_not_allowed} and logging a WARN) so a single out-of-set row can never head-of-line block an in-set successor. A nil or empty AllowedWireTypes would allow every wire type; the composition root passes the closed five-member set so the bus carries exactly the node-facing wire literals a plexd agent consumes today.
The set bounds the node-facing bus only. The relay's signing-key rotation fan-out runs ahead of both skip-and-advance gates, so a signing_key_rotated row still reaches DomainSigningKeyResolver.Subscribe and invalidates the per-Domain public-key cache even though it is never published to a Node. Gating the invalidation on the allowed set would leave a retired key trusted until the process restarts.
Single-replica constraint
The slice runs single-replica only. The handler-side NonceStore is the process-local in-memory store, so the symmetric replay check would degrade silently across replicas if the nonce state were not shared. The composition root therefore refuses to boot when PLEXSPHERE_SSE_STREAM_REPLICAS > 1 is set with the in-memory store wired — the boot guard rejects the misconfiguration before /readyz greens and directs operators to pin replicas=1 or wire a distributed nonce store before scaling. PLEXSPHERE_SSE_STREAM_REPLICAS defaults to 1.
Opt-in configuration
The knobs cmd/plexsphere/sse_factory_prod.go reads to enable and tune the slice:
| Env var | Default | Effect |
|---|---|---|
PLEXSPHERE_SSE_NATS_URL | unset | The opt-in gate. Empty keeps the NonceStore-only wiring and the 501 stub; a non-empty NATS URL dials JetStream and assembles the full publish → relay → consume graph. |
PLEXSPHERE_SSE_STREAM_REPLICAS | 1 | JetStream stream replica count. Boot refuses > 1 while the in-memory NonceStore is wired. |
PLEXSPHERE_SSE_RELAY_INTERVAL | 250ms | Cadence the relay loop ticks at when draining the outbox onto the bus. Parsed as a Go time.Duration; non-positive values are rejected at boot. |
PLEXSPHERE_ACCESS_SIGNER_ENDPOINT | unset | The plexsphere-signer gRPC target the publisher signs envelopes through and the verifier resolves per-Domain public keys against. REQUIRED once the NATS gate is set — the factory refuses to build the slice without it. Reused from the access-issuance signer configuration, so a deployment configures one signer, not two. |
PLEXSPHERE_ACCESS_SIGNER_CLIENT_CERT / PLEXSPHERE_ACCESS_SIGNER_CLIENT_KEY / PLEXSPHERE_ACCESS_SIGNER_SERVER_CA | unset | The mTLS file paths the signer client dials with; the signer always runs behind mTLS. Reused from the access-issuance signer configuration. |
The nonce-set tuning knobs PLEXSPHERE_SSE_NONCE_TTL and PLEXSPHERE_SSE_MAX_LIVE_NONCES are documented under Nonce set design.
Deferred workstream
The un-descope is partial. The following stay deferred and are tracked in the roadmap:
- Head-of-line-blocking mitigation — per-subject
FilterSubjectconsumption and the skip-and-advance schema work onno_signing_keyrows; today an un-provisioned Domain key halts that Domain's relay batch and thesse_relay_skip_total{reason=no_signing_key}counter is the operator signal. - A distributed nonce store — the prerequisite for relaxing the single-replica boot guard to
replicas > 1. - Relaying
session_revokedandnode_secrets_updatedover SSE — these are not in the five-event set, so revocation currently converges only through the reconciliation-pullsessionsblock draining. - The replay-window
410behaviour — theLast-Event-IDout-of-window410 last_event_id_outside_replay_windowarm stays unreachable until the stream(low, high)bounds are threaded through theEventStreamport.
The chainsaw E2E fixture at ../../../tests/e2e/mesh/dispatch-channels-sse/chainsaw-test.yaml now exercises the real plexsphere binary end-to-end with the bus opted in: it boots the production cmd/plexsphere with PLEXSPHERE_SSE_NATS_URL set (and PLEXSPHERE_SSE_STREAM_REPLICAS=1) against NATS JetStream and a live plexsphere-signer over mTLS, commits a dispatch to plexsphere.outbox_events, and asserts it arrives on GET /v1/nodes/{id}/events as a signer-signed action_request frame that the same NSK callback leg settles. A fallback step scales NATS to zero and proves a fresh dispatch still converges through the reconciliation pull alone, so the bus is a pure latency optimisation over a pull that never stops converging. The older wire-format compatibility fixture at ../../../tests/e2e/mesh/sse-signed-envelopes/chainsaw-test.yaml still deploys cmd/sse-stub-plexd as a stub producer/consumer pair to pin the wire format independently of the production binary.
What ships and is load-bearing today, wired into the production binary on the opt-in path:
- The publish pipeline (sign-before-publish with
Nats-Msg-Iddedup), the bounded-TTL nonce store with capacity-LRU eviction and Prometheus gauge, the outbox-relay with cursor advance, wire-type filtering, andErrSigningKeyNotProvisionedskip-and-log, theLast-Event-IDparser with all four arms (empty / numeric / non-numeric / out-of-window; the out-of-window410stays unreachable until the stream bounds are threaded — see the deferred workstream above), the canonical stream-name + subject helpers, theplexsphere.sse_relay_cursormigration, the depguard allow-list, the OpenAPI surface byte-equality drift gate, the in-processEventStreamAdapter, the multi-replica boot guard refusingreplicas > 1with the in-memory NonceStore, and the consumer-side capture harness contract. The relay loop, stream provisioning, and the sqlc-backedRelayStorenow have a production caller —buildFullSSEWiringincmd/plexsphere/sse_factory_prod.go, behind thePLEXSPHERE_SSE_NATS_URLgate. - The
node_state_updateddefault wire event-type, derived from atenancy.NodeRegisteredoutbox row (and every other outbox literal that does not map onto a more specific wire type). - The
policy_updatedwire event-type. The publisher'swireTypeFordispatch table maps the closed policy outbox literals (policy_revision_createdandpolicy_deleted) onto the wire literalpolicy_updated; the producer-side fan-out runs from the compile-service arm — one publish per matched Node on revision-created, one per previously compiled Node on delete. See../policy/events.mdfor the full dispatch table, fan-out algorithm, and payload schema. - The
bridge_config_updatedwire event-type. ThewireTypeFordispatch table collapses the closed seven-member set ofbridge.*outbox literals onto the single wire literalbridge_config_updated, and the four bridge application services each emit one signed envelope per Node hosted by the changed bridge Resource, carrying the whole-object effective bridge config. A bridge-mode Node converges on the reconciliation-pullbridgeblock whether or not the bus is opted in. See../bridge/events.mdfor the full dispatch table, the per-Node fan-out algorithm, theBridgeConfigUpdatedPayloadschema, and the byte-equality contract with the pull snapshot. - The
action_requestandsession_setupwire event-types — the two node-dispatch literals that complete the five-event set. They carry the per-Node action dispatch and per-Node session provisioning payloads the reconciliation pull also surfaces in itsexecutionsandsessionsblocks, so a Node converges on either channel. See../actions/events.mdand../access.md.
Because the bus is an optimisation only, downstream consumers MUST NOT treat the push channel as a correctness dependency: a Node converges through the reconciliation pull (GET /v1/nodes/{id}/state) whether or not the operator has opted the bus in, and the surface answers 501signed_event_bus_not_provisioned for any deployment that has not.
Cross-references
../../../README.md#signed-event-bus— top-level specification of the bus, the per-node SSE surface, the event-type catalogue, and theLast-Event-IDreplay contract../peers.md— Key and Peer Manager bounded sub-context reference; the six peer event_type strings (peer_registered,peer_psk_assigned,peer_deregistered,peer_endpoint_changed,rotate_keys,peer_key_rotated) the relay routes verbatim../key-rotation.md— the key-rotation workflow that emits therotate_keyscommand and thepeer_key_rotatednotification onto this bus.../signing-rotation.md— the Signing Service key-rotation workflow that emits thesigning_key_rotatedevent onto this bus.../policy/events.md— the policy events surface that maps the closed policy outbox set onto thepolicy_updatedwire literal.../bridge/events.md— the bridge events surface that collapses the closed seven-member bridge outbox set onto thebridge_config_updatedwire literal and fans it out one envelope per hosted Node.../../architecture/storage-topology.md— the Postgres node that backsplexsphere.sse_relay_cursorand the per-Domain outbox rows the relay drains.../../contributing/layout.md— bounded-context map row enumeratinginternal/mesh/sseand the depguard allow-list that keeps the bus boundary intact.../../how-to/mesh/inspect-the-event-bus.md— operator how-to for inspecting the live JetStream stream, the cursor row, and the publisher metrics.../../../api/openapi/plexsphere-v1.yaml— OpenAPI spec forGET /v1/nodes/{id}/events, including theLast-Event-IDheader and the registered Problem-Details codes.../../../tests/workspace/mesh_sse_depguard_test.go— workspace alignment test pinning the depguard allow-list for theinternal/mesh/sseboundary.
Ubiquitous language
The terms below travel together across the Go code, the OpenAPI contract, the audit log, the Prometheus metric label values, and operator-facing tooling. Names are preserved verbatim in error messages and structured-log attributes.
| Term | Definition |
|---|---|
| Envelope | The signed-event value type (internal/signing/envelope) carried over the bus: {ID, Type, Scope, KeyID, IssuedAt, Payload, Signature}. The Signature is an Ed25519 detached signature over CanonicalBytes of the envelope with Signature excluded. |
| Domain Event | The business event a payload encodes (e.g. NodeRegistered from internal/identity/tenancy/events (node.go)). The bus does not interpret payloads; the EventType discriminator on the outbox row is reserved for forensic audit emission while the wire envelope's Type is fixed to node_state_updated. |
| Subscriber Stream | A single authenticated plexd Node's SSE subscription on GET /v1/nodes/{id}/events. The subscribe is dual-credential: a plexd Node authenticates with its own NSK (an equality gate — the authenticated Node must equal the addressed {id} — with no ReBAC check; a cross-Node NSK is refused with 403 node_id_mismatch), or an operator authenticates with a session / API token / OIDC credential carrying the node-agent relation. An NSK-authenticated stream re-checks its credential on every keep-alive tick and closes once the NSK stops resolving, so revoking a Node's NSK tears down the streams it already holds open; an operator stream carries no NSK and skips the re-check. One stream per node; the HTTP handler increments sse_active_streams on Subscribe and decrements on the deferred close. |
| Signing Key Resolver | The SigningKeyResolver seam in internal/mesh/sse/signing_key_resolver.go that returns the (KeyID, Scope) for a Domain. Returns ErrSigningKeyNotProvisioned when the per-Domain key has not been minted yet; the relay logs and skips on this sentinel. |
| Nonce Set | The bounded LRU + TTL set in internal/mesh/sse/nonce.go that admits each envelope ID exactly once per freshness window. Add(nonce) → bool: true admits, false is a duplicate the consumer must drop. |
| Last-Event-ID | The Last-Event-ID HTTP header on a reconnect. Resolved by ParseLastEventID in internal/mesh/sse/replay_math.go to the JetStream OptStartSeq the consumer should resume from. |
| Replay Window | The JetStream stream's [currentLow, currentHigh] retention bounds (24h MaxAge by default). A Last-Event-ID below currentLow has aged out; the handler returns 410 Gone with last_event_id_outside_replay_window. |
| Outbox Cursor | The per-stream row in plexsphere.sse_relay_cursor advanced under SELECT … FOR UPDATE SKIP LOCKED by the relay. Carries (stream_name, last_outbox_id, last_xid, updated_at). |
| Stream Replica Factor | The JetStream stream replicas count read from PLEXSPHERE_SSE_STREAM_REPLICAS at boot. Composition refuses to start with replicas > 1 while the in-memory NonceStore is wired. |
| NodeRegistered | A representative Domain Event (the Tenancy bounded context emits it when a node joins). Reaches the bus as an outbox row whose payload the relay forwards into a single envelope. |
| NodeReachabilityChanged | A Domain Event emitted by the per-Domain reachability evaluator (internal/mesh/reachability/evaluator.go) when a Node transitions between healthy, stale, and unreachable. Source: TypeNodeReachabilityChanged in internal/identity/tenancy/events/node.go. Wire-level Type discriminator is node_state_updated (the bus pins one wire type — same as every other Domain Event row). Payload contract: {from_state, to_state, node_id, domain_id, occurred_at}. See ./reachability.md for the state-machine semantics. |
| rotate_keys | A peer-aggregate outbox event_type the Key and Peer Manager appends when an operator requests a Node mesh-key rotation. Unlike the past-tense peer notifications it is an imperative COMMAND addressed to a single Node, telling it to generate a fresh Curve25519 keypair and call POST /v1/keys/rotate. Source: TypeRotateKeys in internal/mesh/peers/events/events.go. Wire-level Type discriminator is node_state_updated (the bus pins one wire type — the per-event discriminator travels on the outbox event_type). See ./key-rotation.md for the rotation workflow. |
| peer_key_rotated | A peer-aggregate outbox event_type the Key and Peer Manager appends when a Node completes a mesh-key rotation; every peer of the rotated Node observes it. The payload carries the rotated Node's new 32-byte Curve25519 public key plus the (kid, wrap_key_version) PSK reference — never PSK plaintext or ciphertext. Source: TypePeerKeyRotated in internal/mesh/peers/events/events.go. Wire-level Type discriminator is node_state_updated. See ./key-rotation.md for the rotation workflow. |
| signing_key_rotated | A signing-aggregate outbox event_type the plexsphere-signer binary appends when a signing-key rotation opens. One outbox row per Node in the scope is written by OutboxEventPublisher.PublishKeyRotated in cmd/plexsphere-signer/event_publisher_outbox.go inside a dedicated publisher-owned pgx.Tx (NOT the rotation transaction — the rotation row commits first; the publisher's tx is independent). The per-Node rows are all-or-nothing within the publisher tx, but a crash between the rotation commit and the publisher tx leaves the rotation persisted with no fan-out (see signing-rotation.md two-phase trade-off). Source: TypeSigningKeyRotated in internal/signing/events/events.go. Payload contract: {event_id, occurred_at, domain_id, scope_kind, scope_id, old_key_id, new_key_id, new_public_key, valid_from, opened_at, closes_at} (the 32-byte new_public_key is base64-encoded on the wire). The row maps onto no member of the node-facing wire-type set, so the relay skips-and-advances it instead of publishing it to a Node. The consumer side is DomainSigningKeyResolver.Subscribe in internal/mesh/sse/signing_key_resolver.go, which calls Reset(DomainID) per event to invalidate the per-Domain public-key cache. See ../signing-rotation.md for the rotation workflow. |
| node_state_updated | The default wire-level Type discriminator the publisher stamps onto every envelope that does NOT map onto a more specific wire literal. Pinned by EventTypeNodeStateUpdated in internal/mesh/sse/publisher.go; the per-row mapping is owned by the wireTypeFor dispatch table in the same file. |
| policy_updated | A second live wire-level Type discriminator the publisher stamps onto envelopes whose outbox-side event_type belongs to the closed policy aggregate set (policy_revision_created or policy_deleted). Pinned by EventTypePolicyUpdated in internal/mesh/sse/publisher.go; the publisher-side dispatch table and the producer-side fan-out are documented in ../policy/events.md. |
| bridge_config_updated | A third live wire-level Type discriminator the publisher stamps onto envelopes whose outbox-side event_type belongs to the closed seven-member bridge aggregate set (bridge.RelayConfigured, the user-access / public-ingress / site-to-site *Configured and *Removed literals). Pinned by EventTypeBridgeConfigUpdated in internal/mesh/sse/publisher.go; the producer-side per-Node fan-out is wired while the consumer-side stays deferred. The publisher-side dispatch table, the fan-out algorithm, and the BridgeConfigUpdatedPayload schema are documented in ../bridge/events.md. |
| PLEXSPHERE_NODE_EVENTS | The JetStream stream name. Pinned by StreamName in internal/mesh/sse/streamname.go; a single source of truth for the publisher's EnsureStream call and the consumer's Replay call. |
| plexsphere.node.events.<domain>.<node> | The per-node JetStream subject template returned by SubjectFor(domainID, nodeID). The wire-format invariants (no ., *, >, no whitespace, no non-printables) are enforced by validateSubjectToken in the same file. |
Envelope schema
The wire envelope is the envelope.Envelope value type defined in internal/signing/envelope/canonical.go. Field semantics:
| Field | Type | Meaning |
|---|---|---|
ID | uuid.UUID (v7) | Unique per envelope. The publisher allocates a fresh UUIDv7 per call so IDs sort naturally by issuance time across replicas without a separate sequence column. The ID also doubles as the nonce key on the consumer side (see Nonce set design). |
Type | string | The wire-level discriminator. Always node_state_updated, pinned by the constant EventTypeNodeStateUpdated in internal/mesh/sse/publisher.go. |
Scope | signing.Scope | The signing scope the envelope was signed under — domain:<uuid> for every per-Domain envelope on this bus. |
KeyID | signing.KeyID | The key that produced Signature. The Resolver returns this verbatim; the publisher never lets the signer pick its own active key. |
IssuedAt | time.Time (UTC) | The publisher's wall-clock instant at envelope construction. Canonicalised to nine fractional digits via CanonicalTimeLayout. |
Payload | json.RawMessage | The Domain Event payload, taken verbatim from the outbox row. CanonicalBytes re-parses and re-emits the payload with sorted keys so a caller cannot leak its own key order into the signed pre-image. |
Signature | []byte | Ed25519 detached signature over CanonicalBytes(env) with Signature excluded. Excluded by the canonicaliser unconditionally — a verifier re-runs CanonicalBytes on the received envelope without clearing the field manually. |
The README's wire-level description of the envelope at README §Signed Event Bus lists a notional separate nonce field; the in-tree implementation uses the envelope ID (UUIDv7) as the nonce key on both publish and consume paths because the ID is already unique-per-envelope within the freshness window. A future dedicated Nonce field would slot into this same call site without touching the security control flow — see the DECISION block in internal/transport/http/v1/handlers/events.go around the nonceKey := ev.Envelope.ID.String() site.
CanonicalBytes is the byte-stable JSON pre-image the signer signs. Encoding rules — sorted object keys, no whitespace, HTML-safe escapes disabled, raw UTF-8, fixed-precision time layout — are pinned in the canonicaliser file's package doc; downstream re-canonicalisation by a verifier is therefore deterministic across Go versions, platforms, and clock resolutions.
JetStream subject layout
Two strings pin the wire contract between the publisher's EnsureStream call and the consumer's Replay call:
- Stream name — the constant
StreamName = "PLEXSPHERE_NODE_EVENTS"ininternal/mesh/sse/streamname.go. This is the SINGLE SOURCE OF TRUTH; both the relay'sEnsureStreamand the SSE handler'sReplaysource the literal from this file. - Per-node subject template —
plexsphere.node.events.<domainID>.<nodeID>, returned bySubjectFor(domainID, nodeID). The fixed prefixsubjectPrefix = "plexsphere.node.events."is unexported; callers always go throughSubjectFor.
validateSubjectToken (same file) rejects each token that:
- is empty (
domain id must not be empty), - contains the NATS subject delimiter
.(collides with the hierarchy), - contains the wildcard
*or the multi-wildcard>(would expand the publish target), - contains whitespace (caught via
unicode.IsSpaceso tab, LF, CR, and U+00A0 all surface a single error class), - contains a non-printable byte (caught via
unicode.IsPrintso the C0/C1 control planes and DEL all hit one branch).
A rejected token surfaces ErrInvalidSubjectToken wrapped with the offending kind and value; callers branch with errors.Is. The publisher reacts by skipping the row before any bus call.
Publish pipeline diagram
The publish pipeline runs strictly in the order shown — the "sign before publish" invariant forbids storing or transmitting an unsigned envelope, so any failure in the resolve, canonicalise, or sign step short-circuits before the JetStream publish.
text
┌────────────────────────────────────────────────────────────┐
│ Postgres plexsphere.outbox_event │
│ + plexsphere.sse_relay_cursor (per-stream) │
└────┬───────────────────────────────────────────────────────┘
│ 1. RunTick: SELECT … FOR UPDATE SKIP LOCKED on cursor
│ ReadOutboxBatchAfter(cursor, batch=64)
v
┌────────────────────┐
│ Relay (relay.go) │ for each row:
└────┬───────────────┘
│ 2. resolve(domainID) ──────────────► SignerClient.PublicKey
│ (gRPC mTLS)
│ KeyID, Scope (or ErrSigningKeyNotProvisioned: skip)
v
┌────────────────────────────┐
│ Publisher.Publish │
│ (publisher.go) │
└────┬───────────────────────┘
│ 3. allocate envelope ID = uuid.NewV7()
│ 4. build envelope { ID, Type=node_state_updated,
│ Scope, KeyID, IssuedAt, Payload }
│ 5. canonicalBytes = envelope.CanonicalBytes(env)
│ 6. signResult = Signer.Sign(scope, keyID, canonicalBytes)
│ ──────► SignerClient.Sign
│ (gRPC mTLS)
│ 7. env.Signature = signResult.Signature
│ 8. wireBody = json.Marshal(env)
│ 9. subject = SubjectFor(domainID, nodeID)
│ ────────────────► JetStream subject
│ plexsphere.node.events.<dom>.<node>
v
┌────────────────────────────┐
│ Bus.Publish(subject, │
│ wireBody, │
│ WithMsgID(ID))│ ──► JetStream stream
└────┬───────────────────────┘ PLEXSPHERE_NODE_EVENTS
│ 10. ack.Sequence (uint64)
v
┌────────────────────────────┐
│ Relay (relay.go) │ 11. AdvanceSseRelayCursor on
│ │ successful suffix; on any
│ │ publish/sign error: log,
│ │ bump counter, halt batch
│ │ without partial advance.
└────────────────────────────┘Failure modes are typed sentinels. ErrSigningKeyNotProvisioned short-circuits at step 2 — the publisher never reaches the sign or publish call. signing.ErrProviderUnavailable and any other Signer.Sign error short-circuit at step 6. Either way the cursor does not advance; the next Tick re-publishes the failed row, and the JetStream Nats-Msg-Id dedup window (keyed on the envelope ID) suppresses any duplicate that might briefly land on the wire.
Last-Event-ID semantics
ParseLastEventID(header, currentLow, currentHigh) in internal/mesh/sse/replay_math.go resolves the SSE handler's Last-Event-ID header into a JetStream OptStartSeq. The four arms are non-overlapping and exhaustive:
| Arm | Header | Bounds | Returned startSeq | Returned error | HTTP status |
|---|---|---|---|---|---|
| 1 | empty | any | currentHigh + 1 | nil | 200 (tail mode; consumer blocks until next publish) |
| 2 | numeric n | currentLow ≤ n | n + 1 | nil | 200 (resume; folds tail-position case n > currentHigh into the resume arm so a future-sequence reconnect blocks at n+1) |
| 3 | numeric n | n < currentLow | 0 | ErrLastEventIDOutsideWindow | 410 Gone, code last_event_id_outside_replay_window |
| 4 | malformed | any | 0 | ErrBadLastEventID | 400 Bad Request |
Both sentinels live in replay_math.go next to their producer:
ErrBadLastEventID— wrapsstrconv.ParseUint's error so the HTTP handler can surface a stable error code while the operator-side log carries the underlying parse detail. Trips on alphabetic input, signed integers (-7,+42), decimal/scientific notation (12.5,1e10), hex literals, surrounding whitespace, and any base-10 integer that overflowsuint64.ErrLastEventIDOutsideWindow— surfaced when the parsednfalls belowcurrentLow. The client falls back to the reconciliation pull atGET /v1/nodes/{id}/stateto rebuild state without lossy replay.
Edge cases the function pins explicitly:
n == 0againstcurrentLow ≥ 1is arm 3 (410). JetStream sequences are 1-based; a0bookmark cannot come from a real delivery.currentLow == 0(only when the stream is empty) with an empty header returnsstartSeq = 1— the consumer blocks until the first publish.- A future-sequence header (
n > currentHigh) withcurrentLow ≤ nis arm 2 — the consumer blocks atn+1until a matching new event is published. The user-story criterion is "Reconnect with Last-Event-ID: <head+1> blocks until a matching new event is published"; the resume arm is the correct home for it.
Nonce set design
The NonceStore in internal/mesh/sse/nonce.go is an in-memory, bounded LRU + TTL set that admits each envelope ID exactly once per freshness window. Its design properties:
- Insertion-time ordering, not access-time. The freshness window is a property of WHEN the nonce was first seen; an access-time LRU would let a steady stream of
Seen()probes keep an aging nonce alive past TTL. - Capacity bound. Construction takes
maxSize >= 1;Addevicts the oldest insertion-time entry when the post-expiry size is at the cap. A non-positivemaxSizeproducesErrInvalidNonceStoreConfigat constructor time. - TTL bound. Construction takes
ttl >= 1ns;Addruns expire-on-touch before any duplicate-detection branch, so a TTL-aged nonce re-Addreturnstrue. - Concurrency. All exported methods are safe for concurrent use across goroutines under a single
sync.Mutex. - Container choice. Backed by
container/list(doubly-linked list) plus amap[string]*list.Elementfor O(1)Add, O(1)Seen, and O(1) capacity eviction. - Prometheus gauge.
sse_nonce_live_size(the constantNonceLiveSizeMetricNameininternal/mesh/sse/metrics.go) is registered byNewviaprometheus.NewGaugeFunc. The callback readslen(map)under the store's mutex at scrape time only, so the steady-state hot path never touches Prometheus state. A nil registerer disables the gauge.
The in-memory store is single-replica-only. The composition root in cmd/plexsphere/sse_factory_prod.go refuses to boot when PLEXSPHERE_SSE_STREAM_REPLICAS > 1 is set with the in-memory NonceStore wired.
Operator knobs:
| Env var | Default | Effect |
|---|---|---|
PLEXSPHERE_SSE_NONCE_TTL | feature default | Freshness window for the nonce set. Parsed as a Go time.Duration; non-positive values are rejected at boot. |
PLEXSPHERE_SSE_MAX_LIVE_NONCES | feature default | Hard upper bound on live nonces. Parsed as a positive integer; non-positive values are rejected at boot. |
Threat model
The bus mitigates four classes of attack along the publish-to-consume path. Each mitigation is implemented in a single, named place so a reader chasing a security claim does not have to assemble it from multiple files.
- Signature forgery. Every envelope is signed with the per-Domain Ed25519 key resolved via the
SigningKeyResolver. The consumer re-runsCanonicalBytesover the received envelope and verifies the detached signature against the per-Domain public key served bySignerClient.PublicKey. A failed verification is logged with theoutcome=signature_failureaudit attribute and bumpssse_envelope_verification_failures_total{outcome=bad_signature}. SeeEventStreamAdapterininternal/mesh/sse/event_stream.gofor the audit emission path and theAuditOutcomeSignatureFailureconstant. - Replay of a previously-valid envelope. The symmetric nonce set rejects a duplicate envelope ID inside the freshness window; the publisher's
IssuedAtfield anchors freshness on the signed pre-image so an attacker cannot mint a far-future timestamp under a captured signature. A duplicate incrementssse_nonce_replay_totaland is dropped with a structured-log line. Both sides (publish-side relay and consume-side handler) share one NonceStore at composition time so the counter aggregates the symmetric check. - Tampering in transit. JetStream guarantees the wire bytes reach the consumer unmodified at the broker level; the end-to-end Ed25519 signature over
CanonicalBytesmakes any in-transit byte mutation observable at verification time regardless of the broker's transport. A mutated payload fails re-canonicalisation or signature verification before the consumer accepts the envelope. - Signer outage. The publisher refuses to write unsigned envelopes:
ErrSigningKeyNotProvisionedshort-circuits the pipeline at the resolver step, BEFORE any bus call. The relay logs a structured WARN line with the Domain ID and bumpssse_relay_skip_total{reason=no_signing_key}. A generic signer error (e.g.signing.ErrProviderUnavailable) short-circuits at the sign step and bumpssse_publisher_sign_failures_total{reason=publish_error}. In either case the cursor does not advance; the next Tick replays the row once the signer recovers.
Multi-replica handoff
The relay supports horizontal scale via the SELECT … FOR UPDATE SKIP LOCKED cursor pattern in internal/mesh/sse/relay.go. Each Tick:
- Begins a Postgres transaction.
- Locks the per-stream cursor row (
stream_name = 'PLEXSPHERE_NODE_EVENTS') inplexsphere.sse_relay_cursorunderFOR UPDATE SKIP LOCKED. If another replica already owns the row, the query yields zero rows and the relay returnsErrCursorBusy— translated to a nil Tick error so the relay-loop driver does not back off on a healthy contention pattern. - Reads up to
batchSizeoutbox rows strictly after the cursor's(transaction_id, occurred_at, outbox_id)triple. - Calls the publish pipeline for each row; on the longest contiguous success suffix, advances the cursor inside the same transaction.
- Commits.
Failure suppresses the cursor advance for any row that follows the last success, so the next Tick (on this or a sibling replica) starts exactly where this one halted. Combined with the JetStream Nats-Msg-Id dedup window (keyed on the envelope ID), the at-least-once boundary is invisible to the SSE consumer: a row that two replicas briefly attempt to publish lands on JetStream once.
A reconnecting subscriber lands on a different replica without gap or duplicate because:
- The replay payload is owned by JetStream, not by the relay. The consumer's
Replay(streamName, startSeq)call (withstartSeqresolved byParseLastEventID) reads the bus directly; the receiving plexsphere replica is a transparent passthrough. - The cursor row's at-least-once boundary applies only to the publish side. A reconnect's
Last-Event-IDdoes not touch the cursor; it touches the JetStream stream's(currentLow, currentHigh)view.
The in-memory NonceStore is single-replica-only: each replica maintains its own bounded set, so a duplicate publish that two replicas race on cannot be defended against in-process across replicas. The composition root therefore refuses to boot when PLEXSPHERE_SSE_STREAM_REPLICAS > 1 is set with the in-memory store wired — operators who scale beyond one replica must wire a distributed nonce backend before scaling. The exact refusal message is pinned in cmd/plexsphere/sse_factory_prod.go and rejects the boot with a "PLEXSPHERE_SSE_STREAM_REPLICAS > 1 is incompatible with the in-memory NonceStore" error directing operators to pin replicas=1 or wire a distributed nonce store before scaling.
Operator runbook
For full step-by-step procedures see ../../how-to/mesh/inspect-the-event-bus.md. The pointers below are the entry points an operator chases when a subscriber complains about gaps, replays, or a stalled reconnect.
- Inspect the JetStream stream. Use the NATS CLI inside the cluster —
nats stream info PLEXSPHERE_NODE_EVENTSreports the current(first, last, age, max_age)triple. The stream name comes from theStreamNameconstant ininternal/mesh/sse/streamname.go; per-node subjects followplexsphere.node.events.<domainID>.<nodeID>and are listable vianats stream subjects PLEXSPHERE_NODE_EVENTS. - Drain the cursor row. The per-stream cursor lives in
plexsphere.sse_relay_cursor. Operators inspect the row withSELECT stream_name, last_outbox_id, last_xid, updated_at FROM plexsphere.sse_relay_cursor WHERE stream_name = 'PLEXSPHERE_NODE_EVENTS';. Theupdated_atcolumn is the last successful Tick instant; a stale value combined with a non-empty outbox is the signal that a publish error is halting batches. - Force a re-emit. Reset the cursor by deleting the cursor advance —
UPDATE plexsphere.sse_relay_cursor SET last_outbox_id = '<earlier-id>', last_xid = <earlier-xid> WHERE stream_name = 'PLEXSPHERE_NODE_EVENTS';. The next Tick re-publishes everything after the new cursor; the JetStreamNats-Msg-Iddedup window (24h) suppresses any duplicate that the original publish already landed. - Watch the Prometheus surface.
sse_envelope_verification_failures_total{outcome}— consumer-side rejections labelledbad_signature,bad_nonce, ordecode_error. A sustained rate is a malformed publisher, a key-rotation gap, or a replay attack.sse_nonce_replay_total—NonceStore.Addreturnedfalse. A spike correlates with a replay storm; a slow drift correlates with a clock skew letting old envelopes fall back into the freshness window.sse_relay_skip_total{reason}— relay rows skipped, labelledno_signing_key(rotation pipeline has not minted the per-Domain key yet).sse_publisher_sign_failures_total{reason}— publish-pipeline errors labelledpublish_error(today the relay's only label), withprovider_unavailableandkey_not_provisionedreserved for future direct-publisher call sites.sse_active_streams— count of currently-open SSE handler subscriptions. Watch against per-Node connection caps to detect leaked subscriptions or a runaway reconnect loop.sse_nonce_live_size— current nonce-set size. Watch againstPLEXSPHERE_SSE_MAX_LIVE_NONCESto detect approaching the bounded-set limit beforeAddlatency degrades.