Skip to content

Action Events — the actions.ActionDispatched outbox literal and the action_request wire fan-out

This document is the authoritative bounded-context reference for the action events surface — the seam between the single closed outbox literal the Action 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 (one event per target Node a dispatch fans out to), the action_request payload field table the Node agent consumes, the AST wire-contract gate that locks the closed set, the post-commit best-effort push posture, and the deferred consumer-side wiring owned by the Signed Event Bus epic.

The action events surface is a translation seam — and only that. The Action Orchestrator emits exactly one outbox literal, actions.ActionDispatched; the SSE publisher speaks the single wire literal action_request. The dispatch table in ../../../internal/mesh/sse/publisher.go (wireTypeFor) maps the outbox literal onto the wire literal, and the dispatch service's atomic persist already unrolls one outbox row per Node the Execution fans out to. 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 Execution aggregate, the events sub-package, the publisher dispatch table, and the SSE wire envelope. Internal code never paraphrases them; documentation and error messages adopt the exact spelling below.

TermDefinitionCode anchor
actions.ActionDispatchedThe single closed outbox event_type literal the Action Orchestrator writes — one row per target Node a dispatch fans out to. It carries the action_request payload the Node agent consumes: the action name and kind, the JSON parameters, the timeout in seconds, and the callback URL.TypeActionDispatched in ../../../internal/actions/events/events.go
action_requestThe single wire literal the SSE publisher dispatches onto the per-Node plexsphere.node.events.<domain>.<node> subject. NOT a member of the actions context's outbox set — it is produced exclusively by the publisher's translation seam from the actions.ActionDispatched literal.EventTypeActionRequest in ../../../internal/mesh/sse/publisher.go
ActionDispatched payloadThe on-the-wire struct the events package owns: the field set and lowercase JSON keys are the wire contract a downstream Node agent trusts without joining back to the Execution aggregate at read time.ActionDispatched in ../../../internal/actions/events/events.go

The translation is one-directional: the Execution aggregate never emits action_request, and the SSE publisher never re-derives actions.ActionDispatched 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 literal can evolve with the aggregate.

Closed outbox vs wire literal split

Closed outbox set — the actions aggregate's surface

The Action Orchestrator emits exactly one outbox event_type string from the shared outbox_events table: actions.ActionDispatched. The events package is intentionally thin — it owns the wire shape of the action-dispatch event, the discriminator constant stored in the outbox event_type column, the constructor invariants, and the JSON marshalling, and nothing else.

The closed shape is gated by the AST-walking workspace check ../../../tests/workspace/actions_event_type_set_test.go, which parses the actions context's source under .../events/, collects every EventType-typed const string literal beginning with the actions. wire prefix, and fails the build if the declared set (literals AND identifiers) is not exactly {actions.ActionDispatched} / {TypeActionDispatched}. A second literal fails the gate; a missing one fails the coverage check. Adding a new action event type is a wire-contract change that must land in the events package AND extend the gate's allow-list in the same commit.

The timeout reconciler deliberately does not add a second event type: a timed-out invocation emits only its per-target transition-log row, not a typed outbox event, precisely because admitting a timeout discriminator would break this closed set. Widening the set (and the SSE wire-type table in lockstep) is a tracked follow-up; see ./model.md (Timeout reconciler).

Wire literal — the SSE publisher's surface

The SSE publisher exposes a small closed set of wire event types today — node_state_updated, policy_updated, bridge_config_updated, and action_request — each a constant on the publisher package. The dispatch helper wireTypeFor(outboxEventType string) string collapses the actions outbox literal onto its single wire literal:

Outbox literalWire literal
actions.ActionDispatchedaction_request
any other outbox literalnode_state_updated (default)

The actions arm is one extension of the seam (alongside the policy and bridge arms). A plexd consumer branches on the single wire literal action_request to receive the per-Node action-request payload; a new actions outbox literal that needed to project onto action_request would be a one-line addition to the dispatch table plus an arm in the closed-event-set gate, with the wire-side enum left untouched.

Why the wire literal is its own discriminator

A reader may ask why action_request is a distinct wire literal rather than folded onto node_state_updated. The split reflects two consumer audiences: a forensic / domain reader switches on the outbox literal actions.ActionDispatched because it names which aggregate event occurred, while the bridge-mode plexd Node on the wire needs exactly one bit — here is an action to run, with its parameters, timeout, and the URL to report back to. A dedicated wire literal action_request lets the Node route the payload to its action runner without inspecting an unrelated state-update envelope, and keeps the consumer-side state machine minimal.

action_request payload

The wire envelope's data field carries the JSON-encoded ActionDispatched payload. The field set and the lowercase JSON keys are the wire contract a downstream Node agent trusts. The struct is built by NewActionDispatched in ../../../internal/actions/events/events.go.

JSON keyTypeDefinition
event_iduuidA fresh per-dispatch-event id minted from the same in-context UUIDv7 generator as the ExecutionID. Distinct from execution_id so the event carries its own identity.
occurred_attimestampWhen the event was produced, coerced to UTC so the payload is timezone-stable.
execution_iduuidThe Execution the dispatch belongs to.
node_iduuidThe single target Node this event addresses. One Execution fans out to one event per target Node, so two targets of the same dispatch carry the same execution_id but distinct node_id.
actionstringThe dispatched action's name.
typestringThe action kind — builtin or hook.
parametersobject | nullThe opaque JSON parameter document passed verbatim to the action; the zero Parameters value serialises as null.
timeout_secondsintegerThe per-dispatch timeout in whole seconds the Node must report a terminal result within.
callback_urlstringThe absolute URL the Node reports its result back to, built per the template {base}/v1/nodes/{node_id}/executions/{execution_id}.

The callback URL the payload carries is the same surface the Node authenticates against with its NSK and reports status advances onto; see ./model.md (Callback path) for the server-side state machine the report drives.

Fan-out — one event per target Node

A single committed dispatch produces one outbox row per target Node, not one row for the whole Execution. The fan-out happens inside the dispatch service's atomic persist, not in a separate wire adapter: the service's RunInTx closure interleaves each CreateTarget with its AppendOutboxEvent, so the Execution header, the N targets, and the N actions.ActionDispatched rows commit together. Each outbox row carries the action_request payload for exactly one Node — the same action, type, parameters, and timeout_seconds, but a per-Node node_id, callback_url, and a fresh event_id.

mermaid
flowchart LR
    D[DispatchService.admit] -->|RunInTx| H[(action_execution header)]
    D -->|N x CreateTarget| T[(action_execution_target rows)]
    D -->|N x AppendOutboxEvent| O[(outbox actions.ActionDispatched rows)]
    O -->|wireTypeFor| W[action_request wire literal]
    W -->|per-Node SSE| N1[Node A]
    W -->|per-Node SSE| N2[Node B]

Why the publish lives after the commit

The wire fan-out is a post-commit best-effort push. The dispatch service calls the actions.WirePublisher port (PublishActionDispatched) declared in ../../../internal/actions/ports.goafter its RunInTx closure commits the aggregate write, the N outbox rows, and the audit row. The WirePublisher is nil-tolerated: a dispatch service constructed without one keeps the outbox-only posture (the action_request rides the outbox relay alone), so single-replica dev rigs and unit tests need not stand up the publisher graph; a non-nil port turns immediate wire emission on as a strict superset of the outbox flow. A publish error is logged at WARN and swallowed — never propagated — because the command has already committed and returned success, and the reconciliation-pull / outbox-relay path is the source of truth a Node converges to even when the best-effort push never lands. The DECISION block on WirePublisher in ports.go and the publishDispatched helper in ../../../internal/actions/services/dispatch.go pin the log-and-continue posture and the rejected propagate alternative.

The port is declared locally rather than importing internal/mesh/sse: the no-cross-context-imports-actions depguard rule denies the actions context any cross-context import, and the dispatch service needs only the single fan-out trigger; the composition root wires the real publisher behind the interface, exactly as the bridge and policy wire adapters do for their contexts.

Deferred consumer-side wiring posture

The producer side of the wire path is wired: the dispatch service calls actions.WirePublisher.PublishActionDispatched after it commits, the composition root binds the real publisher behind the port, and the underlying wire emission flows through the publisher's Publish path where wireTypeFor stamps action_request. 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 stub; a Node recovers a dispatch through the outbox relay path. The producer-side closure stays correct because a Node that programs the same dispatch twice is idempotent at the action runner — a future relay loop that back-fills missed events from JetStream replays the same action_request payload the Node would have seen on the live stream.

Cross-references