Appearance
Policy Events — outbox-domain literals vs the wire-side fan-out
This document is the authoritative bounded-context reference for the policy events surface — the seam between the closed outbox literals the Network Policy Engine emits and the single wire literal the Signed 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 both arms run, 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, the depguard rules that keep the policy domain free of the SSE transport, and the deferred-wiring posture for the relay loop owned by a separate Signed Event Bus epic.
The policy events surface is a translation seam — and only that. The Network Policy Engine speaks the closed outbox literals policy_revision_created and policy_deleted; the SSE publisher speaks the single wire literal policy_updated. The dispatch table in ../../../internal/mesh/sse/publisher.go (wireTypeFor) maps every outbox literal in the policy aggregate's closed set onto the wire literal, and the per-Node fan-out unrolls one wire publish per Node whose label snapshot satisfies the head Policy's selector. The wire fan-out is a best-effort push; the reconciliation-pull snapshot remains the authoritative convergence path.
Ubiquitous-language pin
Three terms travel verbatim across the policy aggregate, 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 |
|---|---|---|
| policy_revision_created | Closed outbox literal the Network Policy Engine writes on every Create (initial revision) and every PATCH (subsequent revision). Carries the policy id, revision id, project id, and the canonicalised rule fingerprint. | TypePolicyRevisionCreated in ../../../internal/identity/tenancy/events/events.go |
| policy_deleted | Closed outbox literal the Network Policy Engine writes on aggregate removal. Denormalises ProjectID and Slug so downstream consumers can purge per-Policy caches without joining back to a row that is already gone. | TypePolicyDeleted in ../../../internal/identity/tenancy/events/events.go |
| policy_updated | Wire literal the SSE publisher dispatches onto the per-Node node.events.<node_id> subject. NOT a member of the policy aggregate's closed outbox event set — it is produced exclusively by the publisher's translation seam from the two outbox literals above. | EventTypePolicyUpdated in ../../../internal/mesh/sse/publisher.go |
The translation is one-directional: the policy aggregate never emits policy_updated, and the SSE publisher never re-derives a policy_revision_created or policy_deleted 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 aggregate.
Closed outbox vs wire literal split
The split between the outbox literals and the wire literal is the load-bearing primitive that lets the policy aggregate evolve its closed event set without breaking the on-the-wire SSE contract a plexd Node consumes.
Closed outbox set — the policy aggregate's surface
The Network Policy Engine emits exactly two event types from the shared outbox_events table — policy_revision_created and policy_deleted. The closed shape is gated by the AST-walking workspace check ../../../tests/workspace/policy_event_type_set_test.go, which parses every .go source file under internal/policy/ (non-test files only), finds every composite literal of type OutboxEvent, extracts the EventType field, 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).
Wire literal — the SSE publisher's surface
The SSE publisher exposes exactly two wire event types today: node_state_updated and policy_updated. Both are constants on the publisher package — EventTypeNodeStateUpdated and EventTypePolicyUpdated. The dispatch table at the publisher's Publish entry point is:
| Outbox literal | Wire literal |
|---|---|
policy_revision_created | policy_updated |
policy_deleted | policy_updated |
| any other outbox literal | node_state_updated |
The dispatch lives in the helper wireTypeFor(outboxEventType string) string on ../../../internal/mesh/sse/publisher.go; the unit test TestPublisher_RoutingDispatch_DefaultsToNodeStateUpdated pins the default arm, and TestPublisher_PolicyUpdatedRouting pins the two-key mapping. A new policy outbox literal that needs to project to the wire literal policy_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 does the policy aggregate emit two literals and the wire carry one? The split reflects three different consumer audiences:
- The SpiceDB tuple-write arm at
../../../internal/authz/sync/mapping.goswitches on the outbox literal because it writes different tuple shapes for create vs delete — the literal is the discriminator. - The per-Node compile arm at
../../../internal/policy/services/compile_service.goswitches on the outbox literal because it runs the compile pipeline on revision-created and runsDeleteByPolicyon delete — again the literal is the discriminator. - The plexd Node on the wire only needs one bit of information: something about the policy that targets me has changed; re-fetch the reconciliation-pull snapshot to converge. A single wire literal
policy_updatedis the smallest payload the consumer needs to drive the convergence; folding the two outbox literals into a single wire literal keeps the consumer-side state machine minimal.
Fan-out algorithm
Both consumer arms unroll one publish per Node whose label snapshot satisfies the head Policy's selector. The arms differ in how they list the matched Node set; the wire-side publish itself is shared.
Revision-created arm — selector-driven fan-out
When the compile service handles a policy_revision_created outbox row, it runs the four-stage projection (compiler.md Projection algorithm) per matched Node, persists one plexsphere.policy_compiled_ruleset row per (Node, Policy) tuple via CompiledRulesetRepo.UpsertForNode, AND emits one wire policy_updated publish per row immediately after the upsert succeeds. The compile service consumes the policy.WirePublisher port (PublishCompiledRulesetUpdated) declared in ../../../internal/policy/ports.go; the production binding wraps *sse.Publisher through the adapter at ../../../cmd/plexsphere/policy_wire_publisher_adapter.go, which constructs an outbox-shaped envelope carrying the row's RulePayload and EventType: "policy_revision_created" so the publisher's wireTypeFor dispatch table maps it onto the wire literal policy_updated. The port is nil-tolerant: a composition root that has not yet wired the adapter passes nil, the compile service skips the publish, and the per-Node compiled row remains the authoritative source of truth for the next reconciliation-pull tick.
Deletion arm — lookup-before-cascade
When the compile service handles a policy_deleted outbox row, it lists the previously compiled Nodes BEFORE the cascade:
- Call
CompiledRulesetRepo.ListNodesByPolicy(ctx, policyID)— returns the per-Policy Node set sorted ascending bynode_idso the per-Node publish order is deterministic across replicas. - Emit one
PublishPolicyDeletedcall carrying the policy id and the slice of Node ids; the production adapter fans out N per-Node wire publishes withEventType: "policy_deleted"and the canonical empty-rules payload[]byte("[]")so the publisher's dispatch maps each onto the wire literalpolicy_updated. - Call
CompiledRulesetRepo.DeleteByPolicy(ctx, policyID)— purges the per-Policy rows in a single idempotent operation.
The ordering is load-bearing: a cascade that ran before the lookup would leave the wire fan-out with an empty Node set and no plexd Node would observe the deletion until the next reconciliation-pull tick. The DECISION block on handleDeleted in ../../../internal/policy/services/compile_service.go pins the lookup-before-cascade ordering and the rejected alternative.
Why the publish lives next to the upsert
The wire publish is emitted from the application-service layer rather than from the persistence layer because the application service is the single seam that has both the matched-Node identity and the canonical compiled bytes in hand. Hoisting the publish into a separate consumer arm would require either re-reading the compiled row off Postgres (an extra round-trip) or carrying the RulePayload through the outbox envelope (a wire-shape coupling the closed outbox set explicitly avoids). The DECISION block above handleRevisionCreated in the compile service pins this rationale.
Payload schema
The wire envelope's data field carries a JSON-encoded PolicyUpdatedPayload whose shape is defined under components/schemas/PolicyUpdatedPayload in ../../../api/openapi/plexsphere-v1.yaml. The schema has five required fields:
| Field | Type | Definition |
|---|---|---|
node_id | uuid | The Node the publish targets — matches the subject of the SSE stream the consumer is reading. |
policy_id | uuid | The Policy whose revision changed (or whose deletion the consumer is observing). |
revision_id | uuid | The head Policy revision id at the moment the compile arm ran. On the deletion path the field carries the last-applied revision id so the consumer can match an in-flight reconcile against the row it is about to purge. |
fingerprint | string (format byte, length 44) | The base64-encoded SHA-256 digest of rule_payload — the same 32-byte value the persisted policy_compiled_ruleset.fingerprint column carries. The base64 form is 44 characters with the canonical = padding. |
rules | array of NodeStateCompiledRule | The canonical-sorted rule list (the same shape the reconciliation-pull NodeStateSnapshot.Policy.rules block carries). The deletion path carries an empty array. |
The schema reuses the existing NodeStateCompiledRule reference so a plexd Node that already understands the reconciliation-pull policy block can decode the wire payload without a separate code path; the bytes inside rules are produced by the same canon.Canonicalise pipeline so a Node receiving a wire push can compare the wire fingerprint against its locally-stored reconciliation-pull digest byte-for-byte.
The PolicyUpdatedPayload schema is referenced by the operation example block on GET /v1/nodes/{nodeId}/events so generated client and dashboard tooling can validate the shape against the generated discriminator without re-implementing the dispatch table on the consumer side.
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 integration tier pins this behaviour: TestPolicyUpdated_WireFanout_NatsMsgIdDedup re-drives the same outbox row, asserts the second emission lands as a duplicate, and verifies the per-Node subject still carries exactly one envelope. The dedup window is owned by the broker configuration; the publisher contributes the deterministic hash, the broker contributes the suppression. The contract is at-most-once on the dedup window — a row replayed past the window will land twice, but the upsert idempotency on (node_id, policy_id) keeps the consumer side safe.
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/{nodeId}/events call; the server resumes the stream from the next id strictly greater than the resume cursor. The contract is documented under the resume cursor section of ../mesh/sse.md. The wire literal policy_updated rides the same resume mechanism as node_state_updated — no policy-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/{nodeId}/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 publish within milliseconds of the compile arm landing the per-Node row.
- 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 Policy revision has already been compiled, fetches the full
NodeStateSnapshot.Policyblock and converges from there regardless of which wire events it has or has not received.
The wire fingerprint matches the NodeStateSnapshot.Policy.fingerprint byte-for-byte so a Node receiving a wire push and a pull snapshot in the same convergence window does not flip-flop: it compares the two digests, observes they are identical, and applies the rules exactly once. The integration tier pins this convergence in TestPolicyUpdated_WireFanout_FingerprintConvergesWithSnapshot. The reconciliation-pull Status today table marks the policy row as Live and cross-references the wire fan-out test.
Depguard rules
The policy events surface keeps the policy aggregate free of the SSE transport through the same depguard rules the upstream aggregate enforces. The full rule definitions live in ../../../.golangci.yml; the bounded-context map row for internal/policy in ../../contributing/layout.md enumerates them in operator-facing form.
The seam-relevant rules for this document are:
- The
policy.WirePublisherport lives in../../../internal/policy/ports.go, and the compile service reaches the SSE publisher exclusively through that port. The compile service NEVER importsinternal/mesh/sse. - The production binding —
policyWirePublisherAdapter— lives in the composition root at../../../cmd/plexsphere/policy_wire_publisher_adapter.go. The composition root is the canonical seam for inter-context translation; the adapter wraps*sse.Publisherand converts the domain-shapedCompiledRulesetinto the publisher's outbox-shaped envelope. - A new bounded context that needs to emit a
policy_updatedwire event MUST declare a port in its own package and reach the publisher through it; direct imports ofinternal/mesh/ssefrom any other bounded context are denied.
Deferred-wiring posture
The producer side of the wire path is complete as of this slice. The deferred work that remains is the relay-loop side:
- The
RelayStoreadapter that bridges the JetStream consumer cursor onto the per-Node SSE response stream. - The
EnsureNodeEventsStreamcall that provisions the JetStream subject on first publish. - The
RunRelaygoroutine that polls the JetStream consumer, serialises the envelope into the SSEevent:/data:/id:triple, and flushes onto the long-poll response writer.
These three pieces are owned by the Signed Event Bus epic per ../../architecture/mesh-event-bus-roadmap.md; the multi-replica gate in ../../../cmd/plexsphere/sse_factory_prod.go refuses a production launch with StreamReplicas > 1 and an in-memory NonceStore so the operator cannot accidentally land in a split-brain configuration before the relay loop is in place. The producer-side closure stays correct because every emission is idempotent on (node_id, policy_id) — 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— Network Policy Engine, the upstream authoring surface and the closed two-event outbox set the publisher consumes through the translation seam../compiler.md— Policy Compiler, the per-Node projection arm that produces the bytes the wire payload carries.../mesh/sse.md— Signed Event Bus, the publisher surface that owns the wire dispatch table and the resume cursor contract.../mesh/reconciliation-pull.md— reconciliation-pull snapshot, the authoritative convergence path the wire push complements.../../architecture/mesh-event-bus-roadmap.md— roadmap that pins the relay-loop deferral and the producer-side closures already landed.../../../internal/mesh/sse/publisher.go— the dispatch table and the deterministic envelope hash.../../../internal/policy/ports.go— theWirePublisherandCompiledRulesetRepoports the compile service consumes.../../../internal/policy/services/compile_service.go— the compile arm that emits one wire publish per matched Node.../../../cmd/plexsphere/policy_wire_publisher_adapter.go— the production binding that wraps*sse.Publisherbehind the port.../../../api/openapi/plexsphere-v1.yaml—PolicyUpdatedPayloadwire schema and theGET /v1/nodes/{nodeId}/eventsoperation example.