18 KiB
Fabro Event Schema V2: Concrete Shape
Date: 2026-04-09
Status: implemented
This document turns the settled design decisions from the event-schema discussion into a concrete wire-contract proposal.
It intentionally supersedes the earlier framing in fabro-event-schema-v2-proposal.md for:
- proposal 1: one canonical persisted log, not two truths
- proposal 2: formalize and generalize the existing
since_seqreplay contract, rather than inventing replay from scratch
Design Decisions Carried Forward
- one canonical persisted event log
- plain hand-coded Rust structs are the authoritative source of truth for the event contract
RunEventremains the canonical semantic event typeseqremains outsideRunEvent, in the store/API envelope- replay stays built around ordered
since_seqcursors - typed Rust consumers matching on
EventBodyremain the primary consumer model - the envelope widens only modestly for execution topology and tool-call correlation:
stage_id,parallel_group_id,parallel_branch_id,tool_call_id - existing durable event families stay broadly intact
- live token/delta noise does not become part of the durable persisted Rust event contract
- snapshots are out of scope for both the durable event contract and the attach API
Contract Source Of Truth
V2 does not adopt schema generation or a registry-first workflow.
The authoritative source of truth for the event contract should be plain, hand-coded Rust structs and enums that model the public wire shape directly.
Implications:
- the Rust event types are the canonical contract
- this document describes that contract and should stay aligned with the Rust types
- any TypeScript types, JSON Schema, or OpenAPI fragments are secondary artifacts, not the source of truth
- codegen is explicitly out of scope for the initial V2 implementation
Why Evolve The Current Model
V2 should evolve Fabro's existing event architecture rather than replace it with a generic event platform.
Earlier drafts of this document proposed a generic reducer contract, a larger ontology-first envelope, and a narrower replacement event catalog. V2 walks that back. The current code's boundary between internal workflow events, RunEvent, and EventEnvelope is stronger and simpler than it first appeared, so evolving that model is cheaper and clearer than replacing it.
The current code already has a strong separation of concerns:
- internal workflow/runtime events in
fabro-workflow - one canonical semantic
RunEvent - a store/API envelope that carries
seqoutside the event payload
That separation is worth preserving. The main V2 changes should be:
- modest envelope widening for execution topology
- cleanup and clarification of event-family boundaries
- keeping the durable event catalog semantic and typed
V2 should not introduce:
- a generic reducer contract based on
entity_type/event_role - canonical persisted token deltas
- snapshot events as a second truth layer
Capability Coverage Decisions
V2 is evolutionary over the current RunEvent surface. It keeps the existing durable event families broadly intact rather than replacing them with a new ontology.
The main additions are:
stage_idin the envelope for concrete stage execution identityparallel_group_idin the envelope for one execution of a parallel nodeparallel_branch_idin the envelope for one branch inside a parallel executiontool_call_idin the envelope for agent tool lifecycle events that need a stable cross-family join key
Everything else should remain in typed EventBody props unless there is a strong cross-family reason to promote it. session_id already exists in the envelope today and stays as-is. tool_call_id is promoted now because agent.tool.* events already carry a stable tool-call identity that other durable families can reference when needed. turn_id is deferred because Fabro does not yet have a durable turn identity that spans the families that would need to join on it.
Exact Delta From Current Code
This is the implementation delta from the current Rust codebase, not the full history of how the design was reached.
Add
- add
stage_id: Option<String>toRunEvent - add
parallel_group_id: Option<String>toRunEvent - add
parallel_branch_id: Option<String>toRunEvent - add
tool_call_id: Option<String>toRunEvent - add
actor: Option<ActorRef>toRunEvent - extend envelope extraction in
stored_event_fields()to populate the new execution-topology fields when known - extend envelope extraction in
stored_event_fields()to populatetool_call_idon tool-lifecycle events when known - update
RunEventserialization and parsing so the new optional envelope fields round-trip cleanly
Keep As-Is
RunEventremains the canonical semantic event typeEventBodyremains the typed tagged union of durable event familiesEventBody::Unknownremains the compatibility valve for unknown event names on readEventEnveloperemains the ordered outer wrapper withseqoutside the event payloadEventEnvelope.payloadremainsEventPayload, notRunEvent- the internal/store
EventEnvelopeRust type stays wrapped as{ seq, payload } - attach/replay remains exact ordered replay from
since_seq, followed by live tailing - current durable event families stay broadly intact
- live token/delta noise remains outside the durable persisted contract
- snapshots remain out of scope
Do Not Do
- do not inline
seqintoRunEvent - do not introduce
entity_type,entity_id, orevent_role - do not replace typed Rust consumers with a generic reducer model
- do not redesign the store envelope
- do not add snapshot events or attach-time synthetic snapshots
- do not persist token deltas or other live UI noise as durable
RunEvents
Canonical Rust Shapes
V2 should model the public contract directly as hand-coded Rust types, following the existing architecture.
pub struct RunEvent {
pub id: String,
pub ts: DateTime<Utc>,
pub run_id: RunId,
pub node_id: Option<String>,
pub node_label: Option<String>,
pub stage_id: Option<String>,
pub parallel_group_id: Option<String>,
pub parallel_branch_id: Option<String>,
pub session_id: Option<String>,
pub parent_session_id: Option<String>,
pub tool_call_id: Option<String>,
pub actor: Option<ActorRef>,
pub body: EventBody,
}
pub struct EventEnvelope {
pub seq: u32,
pub payload: EventPayload,
}
pub struct ActorRef {
pub kind: ActorKind,
pub id: Option<String>,
pub display: Option<String>,
}
pub enum ActorKind {
User,
Agent,
System,
}
RunEvent remains the semantic product event. EventEnvelope remains the ordered store/API wrapper. The store continues to persist validated JSON EventPayload, not typed RunEvent structs.
For wire JSON, EventEnvelope should serialize in flattened form so clients see:
{
"seq": 4861,
"id": "...",
"ts": "...",
"run_id": "...",
"event": "...",
"properties": { ... }
}
That flattening is a wire concern only. It does not move seq into RunEvent, and it does not change the internal/store Rust shape of EventEnvelope.
EventBody remains a hand-coded tagged enum serialized as:
{
"event": "stage.completed",
"properties": { "...": "..." }
}
V2 should also preserve the current unknown-event fallback shape:
EventBody::Unknown {
name: String,
properties: serde_json::Value,
}
This fallback already exists in the current code and should be kept.
Envelope Rules
id,ts,run_id, andeventare always present on the serializedRunEvent.seqis not part ofRunEvent. It stays in the outerEventEnvelope.- Optional envelope fields are omitted, never serialized as
null. - The existing top-level envelope fields remain:
node_idnode_labelsession_idparent_session_id
- V2 adds only these new optional envelope fields:
stage_idparallel_group_idparallel_branch_idtool_call_id
- Other relationship identifiers stay inside typed
properties. turn_idremains in typedproperties; see the deferral decision inCapability Coverage Decisions.actoris optional. When present, it identifies the primary actor for the event.- Set
actoron human- or agent-initiated events where that identity matters to consumers. Example:run.cancel.requestedshould identify the user who initiated the cancel. - Set
actoron durable agent output when the producing session identity matters. Example:agent.messageshould identify the agent session. - Omit
actorfor routine runtime events with no meaningful primary actor. Example:stage.started.
ID Format Conventions
run_idkeeps Fabro's current format: an unprefixed ULID string.stage_idkeeps Fabro's current format:"{node_id}@{visit}".node_idis the stable graph node identifier from the workflow definition.parallel_group_idshould be the durable identity of one execution of a parallel node. The default format should be"{node_id}@{visit}".parallel_branch_idshould be the durable identity of one branch within a parallel execution. The default format should be"{parallel_group_id}:{index}".- Consumers should otherwise treat IDs as opaque strings.
Presence Expectations
stage_idis present on events tied to a concrete stage execution.parallel_group_idis present onparallel.*events and on events emitted inside a parallel execution when that scope is known.parallel_branch_idis present onparallel.branch.*events and on nested events emitted inside a specific branch when that scope is known.session_idandparent_session_idkeep their current meaning for forwarded agent/session activity.tool_call_idis present onagent.tool.*events and on other durable events that directly describe the same tool call.node_labelremains in the envelope for display-oriented consumers.actoris expected on control actions and durable agent output when there is a meaningful user or agent identity to expose. It is usually omitted on routine runtime lifecycle events.
Consumer Model
Rust consumers should keep matching on RunEvent.body using typed EventBody variants.
This document does not adopt:
entity_typeentity_idevent_role- a generic reducer contract
External JSON consumers should continue to:
- match on
"event" - read event-specific values from
"properties" - read
"seq"from the flattened outer event envelope on API/SSE responses - use envelope metadata only for cross-cutting context such as stage, session, execution topology, and tool-call correlation
Replay Contract
Fabro keeps the current replay model:
- ordered events are stored as
EventEnvelope { seq, payload } - API/SSE serialization of
EventEnvelopeshould flattenseqinto the top-level JSON object returned to clients - attach starts from
since_seq - the server replays exact persisted envelopes and then tails live envelopes while the run is active
- SSE keepalive comments are transport frames, not events
V2 does not introduce:
run.snapshotsession.snapshot- API-level attach snapshots
- persisted snapshot events of any kind
The durable model remains simple: replay ordered events, no duplicate truth layer.
Implementation Checklist
An engineer implementing this proposal should make only these structural changes unless a later section explicitly says otherwise.
- Update
RunEventto add:stage_idparallel_group_idparallel_branch_idtool_call_idactor
- Update
RunEvent::to_value()andRunEventparsing inrun_event/mod.rsso the new envelope fields serialize and deserialize. - Extend
StoredEventFieldsandstored_event_fields()inevent.rsto populate:stage_idparallel_group_idparallel_branch_idtool_call_idon tool-lifecycle eventsactorwhen there is a clear primary actor These values should come from the emitter's current execution context for stage and parallel scope, and from event-specific payloads fortool_call_id.
- Leave
EventEnvelopestructurally unchanged:seq: u32payload: EventPayload
- Update API/SSE envelope serialization so wire JSON is flattened:
- top-level
seq - then the
RunEventpayload fields alongside it - no
"payload": { ... }wrapper in JSON responses
- top-level
- Leave the replay/attach flow unchanged in behavior:
- persisted replay from
since_seq - live tail after replay
- no snapshots
- persisted replay from
- Keep the current
EventBodyfamily surface unless there is an explicit product reason to change a specific family. - Keep streaming-noise agent events out of durable
RunEventconversion. - Update the HTTP/API schema docs to reflect both:
- new
RunEventenvelope fields - flattened JSON serialization of
EventEnvelope
- new
EventBody And Property Model
V2 should keep the current hand-coded domain split for prop structs:
- run props in
run.rs - stage and checkpoint props in
stage.rs - agent props in
agent.rs - infra/setup props in
infra.rs - parallel/interview/git/misc props in
misc.rs
That split is part of the design quality. V2 should keep adding hand-coded prop structs, not collapse everything into generic maps.
Durable Event Surface
V2 keeps the current durable family surface broadly intact.
Run
run.createdrun.startedrun.submittedrun.startingrun.runningrun.removingrun.cancel.requestedrun.pause.requestedrun.unpause.requestedrun.pausedrun.unpausedrun.rewoundrun.completedrun.failedrun.notice
Stage And Prompt
stage.startedstage.completedstage.failedstage.retryingstage.promptprompt.completed
Parallel
parallel.startedparallel.branch.startedparallel.branch.completedparallel.completed
Interview / Human Input
interview.startedinterview.completedinterview.timeoutinterview.interrupted
Checkpoint
checkpoint.completedcheckpoint.failed
Agent Durable Events
agent.session.startedagent.session.endedagent.processing.endagent.inputagent.messageagent.tool.startedagent.tool.completedagent.erroragent.warningagent.loop.detectedagent.turn.limitagent.steering.injectedagent.compaction.startedagent.compaction.completedagent.llm.retryagent.sub.spawnedagent.sub.completedagent.sub.failedagent.sub.closedagent.mcp.readyagent.mcp.failedagent.failover
Git
git.commitgit.pushgit.branchgit.worktree.addedgit.worktree.removedgit.fetchgit.reset
Infra And Execution
sandbox.*setup.*cli.ensure.*(legacy only)command.*agent.cli.*pull_request.*artifact.capturedssh.readysubgraph.*edge.selectedloop.restartretro.*
Explicitly Non-Durable Streaming Noise
The current boundary that keeps live token/delta noise out of RunEvent should remain in place.
These stay outside the durable persisted contract:
agent.output.startagent.output.replaceagent.text.deltaagent.reasoning.deltaagent.tool.output.deltaagent.skill.expanded
agent.skill.expanded stays in this non-durable bucket because it is display-oriented expansion metadata, not a durable workflow fact.
If Fabro needs those for UI, they belong in a separate transient stream, not in the canonical persisted Rust event contract.
Example Shapes
Flattened Wire JSON
{
"seq": 4861,
"id": "evt_01JSE1N7RJD1NW2JSDT3W0YQ92",
"ts": "2026-04-08T16:21:11.106Z",
"run_id": "01JSE1M0Q0P8P6KQW9Q6D58Q0E",
"event": "agent.tool.completed",
"stage_id": "code@1",
"node_id": "code",
"node_label": "Code",
"session_id": "ses_child",
"tool_call_id": "call_1",
"parent_session_id": "ses_parent",
"properties": {
"tool_name": "read_file",
"output": {
"summary": "Read docs-internal/events-strategy.md"
},
"is_error": false,
"visit": 1
}
}
In Rust, EventEnvelope still remains { seq, payload: EventPayload }. The example above is only the flattened API/SSE JSON form of that envelope.
Practical Guidance
- Preserve the current one-time canonicalization boundary from internal
Eventto externalRunEvent. - Keep
RunEventsemantic and typed. Do not turn it into a generic reducer envelope. - Keep
seqoutside the event payload. - Widen the envelope only modestly:
stage_id,parallel_group_id,parallel_branch_id, andtool_call_id. - Keep
session_idas the existing top-level session field. - Keep event-specific detail inside typed props.
- Preserve
EventBody::Unknownas the compatibility valve for unknown event names on read. - Do not store token deltas or other live UI noise as durable
RunEvents. - Do not add snapshot events or attach-time synthetic snapshots.
- When adding a new durable event, update the current Rust boundary cleanly:
- internal
Event event_name()- envelope extraction
EventBody- typed props
- affected consumers
- internal
Open Follow-Up
correlation_id-style cross-entity grouping remains deferred until Fabro has a concrete consumer and explicit propagation rules