fabro/docs/internal/events-strategy.md
2026-08-21 18:31:22 -04:00

7.7 KiB

Fabro Events Strategy

Fabro emits structured workflow run events during execution for observability. Events are the durable audit trail for a run: they drive the run store, SSE streaming, CLI progress rendering, and optional JSONL sinks.

Events are distinct from tracing logs. Tracing is developer diagnostics; events are product-facing state transitions and activity records that other systems consume.

Detached runs rely on this distinction. If something needs to be visible after reattach, emit a Event rather than only logging to stderr or detach.log.

Architecture

Engine/Handler -> Event -> Emitter::emit()
                                             |- trace(raw event)
                                             |- canonicalize -> RunEvent
                                             `- on_event(&RunEvent)
                                             |- run store
                                             |- SSE
                                             |- optional JSONL/debug sinks
                                             `- CLI / tests / metrics listeners

The canonical RunEvent is built exactly once in the fabro-workflow::event module.

  • Event (in fabro-workflow) is the internal typed event emitted by engine and handlers.
  • Emitter owns an immutable run_id and converts Event into RunEvent via to_run_event_at().
  • RunEvent (in fabro-types) holds envelope metadata plus a typed body: EventBody. It has no cached JSON fields; the wire format is produced only during serialization.
  • Every listener receives &RunEvent, not &Event.
  • Bypass paths that cannot go through the emitter must call to_run_event() once and reuse the same RunEvent for every sink.

Canonical Envelope

Each serialized RunEvent uses this canonical envelope:

{
  "id": "01960d0c-5d16-7d6e-8f61-9fd6f4a532b5",
  "ts": "2026-03-30T12:00:01.000Z",
  "run_id": "01JQ...",
  "event": "agent.tool.started",
  "session_id": "ses_child",
  "parent_session_id": "ses_parent",
  "node_id": "code",
  "node_label": "Code",
  "actor": {
    "kind": "agent",
    "session_id": "ses_child",
    "parent_session_id": "ses_parent",
    "model": "gpt-5.2"
  },
  "properties": {
    "tool_name": "read_file",
    "tool_call_id": "call_1",
    "arguments": {"path": "src/main.rs"}
  }
}

Always-present fields:

Field Type Notes
id string UUIDv7 event id
ts string UTC timestamp with millisecond precision
run_id string Workflow run id
event string Lowercase dot-notation event name

Optional top-level fields:

Field When present
session_id Agent/session events
parent_session_id Forwarded child-session events
node_id Events tied to a graph node or branch
node_label Display label for node_id; omitted when not applicable
actor The principal responsible for the event

Everything else lives inside properties.

Important rules:

  • Optional envelope fields are omitted, not serialized as null.
  • Event-specific fields do not get flattened into the top level.
  • Actor identity normally lives only in top-level actor: Principal; do not duplicate it in event-specific properties. The exception is run.created, whose properties.provenance.subject is the durable run creator stored in RunSpec; its envelope actor is derived from the same principal.
  • User actors must carry canonical IdP identity through Principal::User { identity, login, auth_method }, not a login-only string.
  • EventPayload validation requires id, ts, run_id, and event.

Naming

The external event name is lowercase dot notation, for example:

  • run.started
  • stage.completed
  • agent.tool.started
  • sandbox.ready
  • parallel.branch.completed

event_name() in the fabro-workflow::event module is exhaustive. Do not use wildcard fallthroughs when adding new variants.

Node And Session Metadata

node_id is the stable graph identifier. node_label is the human-facing display name. Stage events should surface both through the envelope when applicable.

Agent events now use explicit session links:

  • session_id identifies the session that originally emitted the event.
  • parent_session_id identifies the immediate parent session for forwarded child events.
  • Nested sub-agents preserve immediate parentage across boundaries.

AgentEvent::SubAgentEvent no longer exists. Child activity is forwarded as normal agent events with session linkage in the envelope.

Direct-Write Paths

Most events flow through Emitter::emit(). The remaining direct-write paths must use:

  1. to_run_event(run_id, event)
  2. Serialize and redact once
  3. Reuse that exact RunEvent for every sink

Never build the same RunEvent twice if multiple sinks receive it.

Adding A New Event

1. Add the typed event

Add a variant to Event, AgentEvent, or SandboxEvent as appropriate.

2. Add tracing

Extend Event::trace() so the raw event is observable in tracing output.

3. Add an external name

Extend event_name() with the new lowercase dot-notation string.

4. Add the EventBody variant

Add a variant to EventBody in fabro-types/src/run_event/mod.rs with a corresponding props struct. Use #[serde(rename = "dotted.name")] matching the external name from step 3.

5. Map envelope fields and construct EventBody

Update stored_event_fields() and event_body_from_event() in the fabro-workflow::event module:

  • Move node_id, node_label, session_id, and parent_session_id into the envelope when appropriate.
  • Construct the EventBody variant directly from the Event fields.
  • For Event::Agent sub-variants, merge visit into the inner props and lift stage to node_id.
  • For Event::Sandbox sub-variants, unwrap and flatten into the corresponding EventBody variant.

6. Emit it

Prefer Emitter::emit(&Event::...).

Use to_run_event() only for true bypass paths.

For cache-backed lifecycle work, emit slow-path start events only when the operation actually misses cache or waits on remote state. Completion events should represent a real ensure step (inspect, build, pull, or poll), not a configured no-op.

7. Update consumers

Check:

  • CLI progress parsing
  • fabro events
  • store validation
  • tests or fixtures that inspect event names or fields

Consumer Guidance

When writing Rust consumers (listeners, store projections, CLI progress):

  • Match on event.body using EventBody::* variants. This gives you typed access to event-specific fields.
  • Use event.node_id, event.node_label, event.session_id, and event.parent_session_id for envelope metadata.
  • Only use event.event_name() or event.properties() for generic/display purposes (logging, forwarding). These involve serialization and should not be used on hot paths.

When writing external JSON consumers (SSE clients, JSONL parsers):

  • Match on the "event" field for the dot-notation event name.
  • Read event-specific data from "properties".
  • Read stage/branch identity from "node_id" and "node_label".
  • Read agent hierarchy from "session_id" and "parent_session_id".

Do not rebuild or mutate the RunEvent in downstream listeners.

Bypass And Persistence Guarantees

Any JSONL sink, the run store, and SSE should reflect the same canonical envelope bytes after redaction.

An active workflow treats any run-event sink write failure as fatal. It cancels execution and attempts to persist run.failed through the direct sink path. Persistence-error logs must include the full source chain so an HTTP status or transport failure remains visible.

status.json remains the authoritative completion signal for detached runs. Terminal run status should only be written after all post-run work is finished.