Event Stream

GET /v1/events/stream is a Server-Sent Events feed of rows from the orchestrator’s events table in strict insertion order. Subscribers keep an open HTTP connection; the orchestrator pushes one SSE message per inserted row.

Endpoint

GET /v1/events/stream

Query parameters (all optional, all AND-combined):

Param

Effect

node_id

only events whose node_id matches

event_type

only events whose event_type matches exactly

The handler polls the underlying SQLite at 500 ms intervals; new rows push immediately on the next tick. Disconnect is detected via http.CloseNotifier; reconnecting with no cursor replays from row 0.

Wire shape (one SSE data: payload per row):

{
  "id":         "<rowid>",
  "node_id":    "<node-id>",
  "event_type": "<dotted.lowercase.string>",
  "payload":    { /* event-type-specific JSON */ },
  "timestamp":  "<RFC3339>"
}

Event types

The event_type field uses dotted lower-snake case. Two distinct sources populate the table:

  • Node-emitted events — the events the agent POSTs to /v1/events. Examples: ai.policy.decision, ai.deployment.lifecycle, node.unreachable. These drive fleet_nodes derivation in fleet_state.go.

  • Orchestrator-emitted events — events the control plane writes about its own state mutations. PR5 of #725 introduces the first two. These do not drive fleet_nodes (server-side enrollment writes are not a liveness signal); they exist to let runtime caches refresh by subscription instead of polling.

enrollment.node.registered

Emitted exactly once when RegisterNode lands a new row in enrolled_nodes. The documented idempotent re-enroll path (re-running autonomy node enroll against an existing node_id, even with a different --enrollment-ref, returns the original record unchanged) is silent — no second event fires. A subscriber can treat each event as “a new node was enrolled, refresh your cache”.

Payload (eventstore.NodeEnrolledPayload):

{
  "node_id":        "robot-arm-007",
  "domain_id":      "fleet-alpha",
  "enrollment_ref": "deploy:fleet-alpha:v1",
  "enrolled_at":    "2026-05-14T12:00:00Z",
  "enrolled_by":    "ops-runbook",
  "status":         "active"
}

domain_id, enrollment_ref, and enrolled_by are omitempty. Labels are intentionally not in the payload — the consumer can fetch them on-demand via GET /v1/enrollment/{node_id}.

rollout.node_state.transitioned

Emitted by UpsertNodeRolloutState when the persisted state for a node actually changes — either the first insert, or an update where the new (state, directive_id) pair differs from the stored one. A no-op upsert (writer re-asserts the same state on every poll) is silent. The transitioned-at timestamp is intentionally not part of the change-detection comparison so a polling cadence can’t manufacture phantom “changes” by advancing the clock.

Payload (eventstore.NodeRolloutStateTransitionedPayload):

{
  "node_id":         "robot-arm-007",
  "state":           "active",
  "transitioned_at": "2026-05-14T12:00:00Z",
  "directive_id":    "rollout-2026-05-14-001"
}

directive_id is omitempty. The previous state is not included — UpsertNodeRolloutState does not read the prior row, and a value the writer never observed would be worse than absent. A subscriber holding a cache already knows what it thought the value was.

Atomicity contract

Both orchestrator-emitted event types share the same contract:

  • The event row commits in the same SQL transaction as the underlying state mutation. A subscriber that sees an event can trust that SELECT * FROM enrolled_nodes WHERE node_id = <event.node_id> (or the rollout-state equivalent) returns a row.

  • The event fires only on real state change. Idempotent re-writes are silent. This makes “saw an event” usable as a refresh trigger without false positives.

Consumer pattern

A cache that wants near-real-time freshness for one of these event types subscribes once at startup and updates on every push:

GET /v1/events/stream?event_type=enrollment.node.registered

Polling fallback (the default runtime/identity cache PR6 ships with) remains valid for callers that don’t want to hold an SSE connection open. The two paths converge on the same payload shape, so a consumer can switch between them without changing its decoder.

Evidence

  • orchestrator/server_event_stream.go — SSE handler.

  • orchestrator/store.goevents table schema, Ingest, QueryStream.

  • orchestrator/eventstore/event_types.go — exported event_type constants and payload struct definitions.

  • orchestrator/eventstore/append.goAppendEmittedEvent helper used by orchestrator-side emitters.

  • orchestrator/enrollment.goRegisterNode emitter.

  • orchestrator/rollout/store.goUpsertNodeRolloutState emitter.

See Also