Appearance
Bridge Events — closed outbox literals vs the bridge_config_updated wire fan-out
This document is the authoritative bounded-context reference for the bridge events surface — the seam between the closed outbox literals the Bridge Orchestrator emits and the single wire literal the Signed SSE Event Bus publisher dispatches onto the per-Node SSE subject. It covers the ubiquitous-language translation, the outbox-vs-wire split, the per-Node fan-out algorithm the wire adapter runs, the wire payload schema the consumer ingests, the Nats-Msg-Id dedup window that bounds at-most-once delivery on replay, the Last-Event-ID resume contract, the relationship to the reconciliation-pull authoritative convergence path and the byte-equality contract between the two channels, the depguard rules that keep the bridge domain free of the SSE transport, and the deferred consumer-side wiring posture owned by a separate Signed Event Bus epic.
The bridge events surface is a translation seam — and only that. The Bridge Orchestrator speaks the closed seven-member set of bridge.* outbox literals; the SSE publisher speaks the single wire literal bridge_config_updated. The dispatch table in ../../../internal/mesh/sse/publisher.go (wireTypeFor) maps every outbox literal in the bridge aggregates' closed set onto the wire literal, and the per-Node fan-out unrolls one signed wire publish per Node the changed bridge Resource hosts. The wire fan-out is a best-effort push; the reconciliation-pull snapshot remains the authoritative convergence path.
Ubiquitous-language pin
The terms below travel verbatim across the four bridge aggregates, the publisher dispatch table, the SSE wire envelope, the OpenAPI payload schema, and the operator-facing reconciliation snapshot. Internal code never paraphrases them; documentation and error messages adopt the exact spelling below.
| Term | Definition | Code anchor |
|---|---|---|
| bridge.*Configured / bridge.*Removed | The closed seven-member set of past-tense outbox literals the four bridge aggregates write on every mutation: bridge.RelayConfigured, bridge.UserAccessProviderConfigured, bridge.UserAccessProviderRemoved, bridge.PublicIngressRuleConfigured, bridge.PublicIngressRuleRemoved, bridge.SiteToSiteTunnelConfigured, bridge.SiteToSiteTunnelRemoved. Each denormalises the bridge ResourceID, the aggregate surrogate id, and the Slug so a downstream consumer can route without joining back to a row that may already be gone. | the four events sub-packages under ../../../internal/bridge/ (relay/events, useraccess/events, ingress/events, sitetosite/events) |
| bridge_config_updated | The single wire literal the SSE publisher dispatches onto the per-Node plexsphere.node.events.<domain>.<node> subject. NOT a member of any bridge aggregate's closed outbox set — it is produced exclusively by the publisher's translation seam from the seven outbox literals above. | EventTypeBridgeConfigUpdated in ../../../internal/mesh/sse/publisher.go |
| effective config | The whole-object per-bridge-Resource projection the EffectiveConfigBuilder folds from the four aggregate sets plus the per-peer relay-assignment rows. It mirrors the OpenAPI NodeStateBridge wire shape so the SSE wire fan-out and the reconciliation-pull snapshot composer project onto identical bytes. | EffectiveConfig in ../../../internal/bridge/effective/builder.go |
The translation is one-directional: the bridge aggregates never emit bridge_config_updated, and the SSE publisher never re-derives a bridge.*Configured or bridge.*Removed literal on the wire. The single seam is the wireTypeFor dispatch table in the publisher, which keeps the operator-visible vocabulary on the wire small and stable while the domain-side literals can evolve with the four aggregates.
Closed outbox vs wire literal split
The split between the seven outbox literals and the single wire literal is the load-bearing primitive that lets the four bridge aggregates evolve their closed event set without breaking the on-the-wire SSE contract a bridge-mode plexd Node consumes.
Closed outbox set — the bridge aggregates' surface
The Bridge Orchestrator emits exactly seven event types from the shared outbox_events table — one Configured literal per aggregate and one Removed literal for each of the three many-per-Resource aggregates (BridgeRelay, being a singleton, has only a Configured form). The closed shape is gated by the AST-walking workspace check ../../../tests/workspace/bridge_event_type_set_test.go, which parses this context's source, finds every event_type literal reaching an AppendOutboxEvent call, and fails the build if any literal escapes the closed set. The closed set is documented in full under model.md (Closed outbox event set).
A cross-aggregate validator refusal never reaches the outbox at all. The pre-persist validation pipeline runs after the ReBAC check and before the persist transaction opens, so a refused command never opens the write transaction, writes no outbox row, and therefore produces no bridge_config_updated wire event — only a committed mutation emits one of the seven literals above. See ./validation.md for the validator's refusals and why they leave no trace on the per-Node SSE fan-out.
Wire literal — the SSE publisher's surface
The SSE publisher exposes three wire event types today: node_state_updated, policy_updated, and bridge_config_updated. All three are constants on the publisher package — EventTypeNodeStateUpdated, EventTypePolicyUpdated, and EventTypeBridgeConfigUpdated. The dispatch table at the publisher's Publish entry point collapses all seven bridge literals onto one wire literal:
| Outbox literal | Wire literal |
|---|---|
bridge.RelayConfigured | bridge_config_updated |
bridge.UserAccessProviderConfigured | bridge_config_updated |
bridge.UserAccessProviderRemoved | bridge_config_updated |
bridge.PublicIngressRuleConfigured | bridge_config_updated |
bridge.PublicIngressRuleRemoved | bridge_config_updated |
bridge.SiteToSiteTunnelConfigured | bridge_config_updated |
bridge.SiteToSiteTunnelRemoved | bridge_config_updated |
| any other outbox literal | node_state_updated (default) |
The dispatch lives in the helper wireTypeFor(outboxEventType string) string on ../../../internal/mesh/sse/publisher.go; the bridge arm is the third extension of the seam (after the two-key policy arm), validating the dispatch against a fan-out shape where one outbox row maps onto N per-Node envelopes — the fan-out lives in the bridge wire adapter, never in this dispatch helper. A new bridge outbox literal that needs to project onto bridge_config_updated is a one-line addition to the dispatch table plus an arm in the closed-event-set gate; the wire-side enum stays untouched.
Why the literals diverge
A reader who has not seen the seam may ask: why do the bridge aggregates emit seven literals and the wire carry one? The split reflects two different consumer audiences:
- The per-aggregate domain consumers switch on the outbox literal because a
Configuredand aRemovedevent drive different downstream behaviour (e.g. SpiceDB tuple writes, cache purges) — the literal is the discriminator, and it also tells a forensic reader which of the four aggregates changed. - The bridge-mode plexd Node on the wire only needs one bit of information: something about the bridge configuration of a bridge Resource that hosts me has changed; here is my full effective bridge config — program it. A single wire literal
bridge_config_updatedcarrying the whole-object effective config is the smallest payload the consumer needs to converge; folding the seven outbox literals into a single wire literal keeps the consumer-side state machine minimal and lets the Node diff by field presence rather than by per-aggregate event type.
Fan-out algorithm
The wire adapter unrolls one signed envelope per Node hosted by the changed bridge Resource. A single committed bridge-aggregate change produces one outbox row; the adapter expands that row into N per-Node publishes.
One committed change → one effective config → N envelopes
The four bridge application services each call the bridge.WirePublisher port (PublishBridgeConfigUpdated) declared in ../../../internal/bridge/ports.goafter their RunInTx closure commits the aggregate write, the outbox row, and the audit row. The production adapter, wired at the composition root, then:
- Enumerates the Nodes the addressed bridge Resource hosts through the
bridge.BridgeNodeListerport (ListByBridgeResource) declared in the same file — the1-bridge-row → N-Nodediscovery seam. - Builds the effective config once through the
effective.EffectiveConfigBuilder(which loads the four aggregate sets plus the per-peer relay-assignment rows and folds them into a single deterministicEffectiveConfigvalue). - Emits one envelope per Node whose source
event_typeis the bridge outbox literal the change produced, so the publisher'swireTypeFordispatch maps each onto the single wire literalbridge_config_updated.
Building the effective config once and reusing it across the per-Node fan-out keeps the projection deterministic: the builder re-sorts providers, rules, and tunnels by slug ascending and relay assignments by peer-node-id ascending, so two consecutive builds against the same snapshot produce byte-equal output and every Node receives identical bytes for the same bridge Resource.
Why the publish lives after the commit
The wire publish is emitted from the application-service layer after the RunInTx closure has already committed, not from inside the transaction. The service is the single seam that has both the committed bridge ResourceID and the post-commit success in hand. A nil WirePublisher makes the call a no-op so the idempotent no-op path's early return naturally skips it. A publish error is logged at WARN level and swallowed — never propagated — because the command has already committed and returned success to the caller, and the reconciliation-pull GET /v1/nodes/{id}/state is the source of truth a Node converges to even when the best-effort push never lands. The DECISION block on publishConfigUpdated in each bridge service (for example ../../../internal/bridge/relay/services/relay_service.go) pins the log-and-continue posture and the rejected propagate alternative.
Payload schema
The wire envelope's data field carries a JSON-encoded BridgeConfigUpdatedPayload whose shape is defined under components/schemas/BridgeConfigUpdatedPayload in ../../../api/openapi/plexsphere-v1.yaml. The schema has three required fields:
| Field | Type | Definition |
|---|---|---|
node_id | uuid | The Node the publish targets — equals the {id} path parameter of the SSE subscription the envelope is delivered on. |
bridge_resource_id | uuid | The bridge Resource aggregate the delivered-config projection was derived from. Two bridge Resources addressing the same Node produce two envelopes carrying distinct bridge_resource_id values; the consumer merges them by sorting on bridge_resource_id ascending. |
effective_config | NodeStateBridge | The per-Node bridge-orchestrator delivered-config block the addressed Node MUST program for the named bridge Resource. Each child block is present-but-nullable so plexd's reconcile loop can diff by field presence. |
The schema reuses the existing NodeStateBridge reference so a bridge-mode plexd Node that already understands the reconciliation-pull bridge block can decode the wire payload without a separate code path. The bytes inside effective_config are produced by the same effective.EffectiveConfigBuilder projection the pull snapshot composer consumes, so a Node receiving a wire push can compare the wire effective_config against its locally-stored reconciliation-pull block byte-for-byte (see Reconciliation-pull relationship).
On a bridge-Resource teardown, the controller emits one empty-config envelope per previously-addressed Node so the Node purges the configuration it had programmed for that bridge Resource.
Nats-Msg-Id dedup
The publisher computes a deterministic envelope hash and threads it as the Nats-Msg-Id header on the underlying JetStream publish. The hash covers the canonical envelope bytes — domain id, node id, wire event type, payload, and the publisher's clock-stamped event id — so two replicas of cmd/plexsphere processing the same outbox row emit byte-identical envelopes with byte-identical headers. JetStream's de-duplication window suppresses the second emission as ack.Duplicate=true; the consumer sees the row exactly once. The publish pipeline that owns this header is documented under ../mesh/sse.md. The contract is at-most-once on the dedup window — a row replayed past the window will land twice, but the whole-object effective config is idempotent to apply, so a Node that programs the same bytes twice converges to the same state.
Last-Event-ID resume
The SSE response stream sets the id: field on every event to the publisher's monotonic event id. A plexd Node reconnecting after a disconnect threads its last-seen id back as the Last-Event-ID: request header on the next GET /v1/nodes/{id}/events call; the server resumes the stream from the next id strictly greater than the resume cursor. The four-arm resume contract is documented under the Last-Event-ID semantics section of the SSE reference. The wire literal bridge_config_updated rides the same resume mechanism as node_state_updated and policy_updated — no bridge-specific cursor or per-event-type filter; a single cursor advances over every event the subject carries.
Reconciliation-pull relationship
The wire push is best-effort; the reconciliation-pull snapshot at GET /v1/nodes/{id}/state is the authoritative convergence path. The two surfaces complement each other:
- The wire push is the latency primitive — when the SSE stream is healthy and the dedup window is intact, a Node observes the
bridge_config_updatedenvelope within milliseconds of the bridge command committing. - The reconciliation-pull snapshot is the correctness primitive — a Node that reconnects after a long disconnect, or that comes online for the first time after a bridge configuration has already changed, fetches the full
NodeStateSnapshot.bridgeblock and converges from there regardless of which wire events it has or has not received.
The byte-equality contract is load-bearing: the SSE effective_config bytes are equal to the GET /v1/nodes/{id}/statebridge block for the same Node and bridge Resource. Both channels project from the same effective.EffectiveConfigBuilder, so a Node receiving a wire push and a pull snapshot in the same convergence window does not flip-flop — it compares the two blocks, observes they are identical, and applies the configuration exactly once. The parity is exercised by the integration suite at tests/integration/bridge_config_updated_pull_parity_test.go. The reconciliation-pull four-block convergence table marks the bridge row as Live and cross-references this surface.
Depguard rules
The bridge events surface keeps the four bridge aggregates free of the SSE transport through the bridge context's dedicated depguard rule. The full rule definitions live in ../../../.golangci.yml; the bounded-context map row for internal/bridge in ../../contributing/layout.md enumerates them in operator-facing form.
The seam-relevant rules for this document are:
- The
bridge.WirePublisher,bridge.BridgeNodeLister, andbridge.RelayAssignmentReaderports all live in../../../internal/bridge/ports.go, declared with only the in-context value objects (ResourceID,NodeID). The bridge application services reach the SSE publisher, the Node-enumeration read model, and the relay-assignment read model exclusively through these ports. No bridge package importsinternal/mesh/sse,internal/mesh/state, orinternal/mesh/peers. - The
EffectiveConfigvalue object the builder produces is declared with primitives only — no bridge value objects and nointernal/meshorinternal/identitytypes — so the snapshot composer'sBridgeSourceport ininternal/mesh/statecan read it without importing the bridge value objects, and the SecretRef stays a plain string that a downstream consumer cannot accidentally dereference. - The composition root is the only place both contexts meet: it wires the real
*sse.Publisher, the effective-config builder, and the Node lister behind the bridge ports, exactly as the policy wire adapter wires the publisher behindpolicy.WirePublisher. A new bounded context that needs to emit abridge_config_updatedwire event MUST declare its own port and reach the publisher through it; direct imports ofinternal/mesh/ssefrom any other bounded context are denied.
Deferred consumer-side wiring posture
The producer side of the wire path is wired: the four bridge services call bridge.WirePublisher.PublishBridgeConfigUpdated after they commit, the composition root binds the real publisher behind the port, and the underlying wire emission flows through sse.PublisherAPI.Publish. The deferred work that remains is the consumer-side /v1/nodes/{id}/events HTTP plumbing — the relay loop, the JetStream stream provisioning, the per-Domain signature verification, and the ReBAC node-agent enforcement — which is owned by the Signed Event Bus epic and tracked in ../../architecture/mesh-event-bus-roadmap.md. Until that consumer-side wiring lands, the GET /v1/nodes/{id}/events endpoint stays on its 501 Not Implemented stub; a bridge-mode Node converges through the reconciliation-pull bridge block, which is live. The producer-side closure stays correct because the whole-object effective config is idempotent to apply — a future relay loop that back-fills missed events from JetStream replays the same envelope bytes the Node would have seen on the live stream.
Cross-references
./model.md— Bridge Orchestrator, the upstream authoring surface and the closed seven-event outbox set the publisher consumes through the translation seam.../mesh/sse.md— Signed SSE Event Bus, the publisher surface that owns the wire dispatch table, theNats-Msg-Iddedup header, and the resume cursor contract.../mesh/reconciliation-pull.md— reconciliation-pull snapshot, the authoritative convergence path the wire push complements and the source of the byte-equality contract.../mesh/relay-fallback.md— the relay-fallback surface whose per-peer fallback-endpoint assignments the effective-config builder folds into the relay block.../../architecture/mesh-event-bus-roadmap.md— roadmap that pins the consumer-side wiring deferral and the producer-side closures already landed.../../../internal/bridge/ports.go— theWirePublisher,BridgeNodeLister, andRelayAssignmentReaderports the bridge services and the effective-config builder consume.../../../internal/bridge/effective/builder.go— theEffectiveConfigBuilderthat folds the four aggregate sets plus the relay-assignment rows into the deterministicEffectiveConfigboth channels project.../../../internal/mesh/sse/publisher.go— the dispatch table, the deterministic envelope hash, and thebridge_config_updatedwire constant.../../../api/openapi/plexsphere-v1.yaml—BridgeConfigUpdatedPayloadwire schema and theGET /v1/nodes/{id}/eventsoperation.