This commit is contained in:
Bryan Helmkamp 2026-04-09 17:24:30 -04:00
parent 587bd6f5c5
commit c317603971
4 changed files with 1627 additions and 0 deletions

View file

@ -0,0 +1,376 @@
# Event Schema Competitive Analysis
Date: 2026-04-08
This report compares the event schemas used by:
- Claude Sessions API
- Claude Code
- Goose
- OpenAI Codex
- OpenCode
- pi-mono
Goal: identify patterns Fabro should copy, avoid, or formalize more clearly.
## Executive Summary
Fabro's current event model is already ahead of most of the field on one important point: it has a canonical envelope with stable metadata (`id`, `ts`, `run_id`, `event`, optional `session_id`, `parent_session_id`, `node_id`, `node_label`) and a typed internal-to-external mapping.
The biggest improvement opportunities are not "more events." They are:
1. Keep transport concerns separate from domain events, but document them as part of the contract.
2. Make every streamed event part of one explicit public schema. Avoid opaque blobs and server-injected fields that the schema does not admit.
3. Add more first-class correlation fields where the UI or downstream systems need them, especially `turn_id`, `message_id`, `tool_call_id`, `request_id`, and retry/attempt IDs.
4. Make retry, stop, idle, and requires-action states machine-readable unions instead of loose strings.
5. Be explicit about delta vs snapshot semantics and replay behavior.
## Fabro Baseline
Fabro's current strategy is documented in `docs-internal/events-strategy.md`. The canonical external shape is:
```json
{
"id": "uuidv7",
"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",
"properties": { "...": "..." }
}
```
That envelope is stronger than most comparator systems. It gives Fabro stable top-level metadata, keeps event-specific data inside `properties`, and avoids flattening arbitrary fields into the root.
Relevant current Fabro sources:
- `docs-internal/events-strategy.md`
- `lib/crates/fabro-workflow/src/event.rs`
- `lib/crates/fabro-types/src/run_event/mod.rs`
- `lib/crates/fabro-agent/src/types.rs`
## Comparison Matrix
| System | Public event surface | Discriminator | Universal envelope fields on every event | Replay / ordering story | Main strength | Main weakness |
| --- | --- | --- | --- | --- | --- | --- |
| Claude Sessions | 20-event public union | `type` | `id`, `processed_at` | Event IDs exist; replay semantics are not part of the event payload | Very explicit, stable union with typed nested states | Less transport detail and fewer workflow-specific events |
| Claude Code | 24 core SDK messages, 31 stdout/control variants | `type`, often `subtype` | Usually `uuid`, `session_id`; no universal timestamp | Streaming is batched/coalesced; control and domain share the same channel | Rich task, hook, status, and tool progress | Nested stream payload is opaque at runtime; control and data are mixed |
| Goose | 7 `MessageEvent` variants | `type` | None in JSON payload; SSE `id` is outside payload | Strong SSE replay via monotonic seq + `Last-Event-ID` + replay buffer | Reattach/replay semantics are clear | Transport fields are injected outside schema; payload typing is shallow |
| Codex SDK | 8 thread events | `type` | No universal ID/timestamp | Ordered stream, no replay contract in payload | Very simple client model | Too minimal for rich UIs and analytics |
| Codex app-server | 49 notification methods | `method` + `params` | No universal ID/timestamp in params | Ordered notifications, no seq/replay field | Richest low-level protocol in the set | Fragmented event story; transport shape leaks into the schema |
| OpenCode | 45 generated event variants | `type` + `properties` | No universal ID/timestamp | Plain SSE; client supports SSE IDs but server does not emit them | Broadest app/runtime event coverage | Wire/schema drift and no universal envelope |
| pi-mono | 12 assistant stream events, 10 agent events, 14 session events | `type` | Session header only; not per event | JSONL stream, no replay contract | Excellent streaming lifecycle grammar | No durable universal envelope for downstream consumers |
## System Notes
### Claude Sessions
What it does well:
- One explicit public union.
- Every event has `id`, `type`, and `processed_at`.
- Tool confirmation, custom tool results, MCP tool use, session errors, session status, and model-span events are all first-class.
- Terminal and waiting states are structured. `session.status_idle.stop_reason` is not a loose string; it is a small union.
- Error reporting is structured. `session.error.error` is a tagged union, not just a message.
Why it matters for Fabro:
- This is the cleanest example of a public agent-session event API that is still small enough to understand.
- The main idea to copy is not the exact event list. It is the discipline: explicit tagged unions for stop reasons, errors, and status transitions.
Sources:
- `https://platform.claude.com/docs/en/api/beta/sessions/events/stream`
- `https://platform.claude.com/docs/specs/merged.53db30dfcc06f431.json.gz`
### Claude Code
Schema shape:
- `SDKMessageSchema` contains 24 core message variants.
- `StdoutMessageSchema` expands the stdout protocol to 31 variants once control messages and keep-alives are included.
- Many events use `type: "system"` plus a `subtype`, for example `init`, `status`, `api_retry`, `hook_started`, `task_progress`, and `session_state_changed`.
- Streaming assistant output is wrapped as `type: "stream_event"`.
What it does well:
- It covers more than just model output: task lifecycle, hook lifecycle, compaction boundaries, retries, authentication state, file persistence, tool progress, prompt suggestions.
- It carries `uuid` and `session_id` widely, which is useful for correlation.
- It includes explicit session-state transitions (`idle`, `running`, `requires_action`).
What is weak:
- The nested streaming payload is not explicitly validated at runtime. `RawMessageStreamEventPlaceholder` is `z.unknown()`.
- Control protocol messages live in the same stream as domain messages.
- `type: "system"` plus `subtype` is workable, but less ergonomic than a flatter public union.
- There is no universal top-level timestamp on every event.
Why it matters for Fabro:
- Copy the breadth, not the shape.
- Avoid opaque inner payloads in public schemas.
- Avoid mixing keep-alive/control/config traffic into the same schema that product consumers use for analytics and UI rendering.
Sources:
- `/Users/bhelmkamp/p/AnkanMisra/claude-code/src/entrypoints/sdk/coreSchemas.ts`
- `/Users/bhelmkamp/p/AnkanMisra/claude-code/src/entrypoints/sdk/controlSchemas.ts`
- `/Users/bhelmkamp/p/AnkanMisra/claude-code/src/remote/sdkMessageAdapter.ts`
- `/Users/bhelmkamp/p/AnkanMisra/claude-code/src/cli/transports/ccrClient.ts`
- `/Users/bhelmkamp/p/AnkanMisra/claude-code/src/utils/sdkEventQueue.ts`
### Goose
Schema shape:
- One small SSE payload union: `Message`, `Error`, `Finish`, `Notification`, `UpdateConversation`, `ActiveRequests`, `Ping`.
- SSE `id:` carries a monotonic sequence number.
- Session replay uses `Last-Event-ID` plus a replay buffer.
- `request_id` and `chat_request_id` are injected at the SSE framing layer, not modeled in the payload type.
What it does well:
- Clear reattach story.
- Monotonic sequence numbers are transport-level, not payload-level.
- `ActiveRequests` lets the client discover in-flight work when reconnecting.
What is weak:
- The public event payload omits fields the client actually consumes.
- `Notification.message` is effectively an untyped object.
- The session stream also emits comment heartbeats outside the schema, and there is a separate `Ping` payload variant in the shared enum. That split is easy to drift.
Why it matters for Fabro:
- Goose is the best example here for replay and reconnect semantics.
- The lesson is not "put sequence numbers in the payload." The lesson is "formalize replay outside the payload, and do not rely on undocumented injected fields."
Sources:
- `/Users/bhelmkamp/p/block/goose/crates/goose-server/src/routes/reply.rs`
- `/Users/bhelmkamp/p/block/goose/crates/goose-server/src/routes/session_events.rs`
- `/Users/bhelmkamp/p/block/goose/crates/goose-server/src/session_event_bus.rs`
- `/Users/bhelmkamp/p/block/goose/ui/desktop/openapi.json`
- `/Users/bhelmkamp/p/block/goose/ui/desktop/src/hooks/useSessionEvents.ts`
### OpenAI Codex
There are really two event systems:
1. The TypeScript SDK `ThreadEvent` surface.
2. The app-server `ServerNotification` protocol.
SDK shape:
- 8 high-level events: thread started, turn started/completed/failed, item started/updated/completed, fatal stream error.
- Rich detail is pushed down into `ThreadItem`, which includes `agent_message`, `reasoning`, `command_execution`, `file_change`, `mcp_tool_call`, `web_search`, `todo_list`, and `error`.
App-server shape:
- 49 server notification methods.
- Notifications are discriminated by `method`, with a typed `params` object.
- Coverage includes thread lifecycle, turn lifecycle, item lifecycle, deltas, token usage, command output, MCP progress, model reroutes, config warnings, and experimental realtime notifications.
What it does well:
- Good separation of a simple developer-facing SDK from a richer system protocol.
- The low-level protocol is broad and explicit.
- Experimental notifications are clearly labeled as experimental.
What is weak:
- The event story is fragmented. "Which schema should I build against?" depends on which integration layer you pick.
- There is no universal timestamp or universal event ID in the event bodies.
- `method` + `params` is transport-shaped. It works well for JSON-RPC, but it is not as clean as a transport-agnostic event envelope.
Why it matters for Fabro:
- If Fabro needs both a high-level SDK and a low-level protocol, document the layering explicitly.
- If Fabro only needs one event stream, a single canonical envelope is simpler than method-shaped notifications.
Sources:
- `/Users/bhelmkamp/p/openai/codex/sdk/typescript/src/events.ts`
- `/Users/bhelmkamp/p/openai/codex/sdk/typescript/src/items.ts`
- `/Users/bhelmkamp/p/openai/codex/sdk/typescript/src/thread.ts`
- `/Users/bhelmkamp/p/openai/codex/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts`
- `/Users/bhelmkamp/p/openai/codex/codex-rs/app-server-protocol/src/protocol/common.rs`
- `/Users/bhelmkamp/p/openai/codex/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartedNotification.ts`
- `/Users/bhelmkamp/p/openai/codex/codex-rs/app-server-protocol/schema/typescript/v2/TurnCompletedNotification.ts`
- `/Users/bhelmkamp/p/openai/codex/codex-rs/app-server-protocol/schema/typescript/v2/AgentMessageDeltaNotification.ts`
- `/Users/bhelmkamp/p/openai/codex/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecOutputDeltaNotification.ts`
### OpenCode
Schema shape:
- One generated `Event` union with 45 variants.
- `GlobalEvent` adds `directory` plus `payload: Event`.
- Events use `type` plus a `properties` object.
- Coverage includes questions, permissions, messages, message parts, session status/idle/compacted/error/diff, workspace readiness, PTYs, worktrees, VCS, file edits, MCP, TUI commands, and more.
What it does well:
- Broad coverage.
- Generated API types from the server surface.
- Clear split between session-scoped events and global events.
- Status is partly structured. `SessionStatus` is a union of `idle`, `retry`, and `busy`.
What is weak:
- No universal event ID.
- No universal timestamp.
- No replay cursor or sequence field.
- The wire stream emits `server.heartbeat`, but the generated `Event` union does not include it.
- The global event schema says `directory` is present, but the initial global `server.connected` and heartbeat frames omit it.
Why it matters for Fabro:
- OpenCode shows how far a generated event surface can go.
- It also shows the cost of not having a canonical envelope: clients must reconstruct correlation from nested `sessionID`, `messageID`, `partID`, and route-specific wrappers.
Sources:
- `/Users/bhelmkamp/p/anomalyco/opencode/packages/sdk/js/src/v2/gen/types.gen.ts`
- `/Users/bhelmkamp/p/anomalyco/opencode/packages/sdk/js/src/v2/gen/core/serverSentEvents.gen.ts`
- `/Users/bhelmkamp/p/anomalyco/opencode/packages/opencode/src/server/server.ts`
- `/Users/bhelmkamp/p/anomalyco/opencode/packages/opencode/src/server/routes/global.ts`
- `/Users/bhelmkamp/p/anomalyco/opencode/packages/opencode/src/server/event.ts`
- `/Users/bhelmkamp/p/anomalyco/opencode/packages/web/src/content/docs/server.mdx`
### pi-mono
Schema shape:
- `AssistantMessageEvent` has 12 streaming variants: `start`, block start/delta/end for text, thinking, and tool calls, then `done` or `error`.
- `AgentEvent` has 10 lifecycle variants across agent, turn, message, and tool execution.
- `AgentSessionEvent` extends `AgentEvent` with 4 session-only retry/compaction events.
- JSON mode starts with a session header, then emits JSONL events.
What it does well:
- Excellent streaming lifecycle grammar.
- Strong layering:
- low-level assistant stream events
- mid-level agent lifecycle events
- high-level session events
- The proxy mode has a bandwidth-optimized streaming shape that intentionally strips partial message snapshots and reconstructs them client-side.
What is weak:
- There is no universal per-event envelope.
- There are no event IDs or replay semantics.
- Timestamping is inconsistent. The session header has a timestamp, and some embedded message objects have timestamps, but not every event line does.
Why it matters for Fabro:
- pi-mono is the best example here for event layering and start/delta/end/done grammar.
- Fabro should borrow that lifecycle discipline if it expands live agent streaming, but keep Fabro's stronger envelope.
Sources:
- `/Users/bhelmkamp/p/badlogic/pi-mono/packages/ai/src/types.ts`
- `/Users/bhelmkamp/p/badlogic/pi-mono/packages/agent/src/types.ts`
- `/Users/bhelmkamp/p/badlogic/pi-mono/packages/agent/src/proxy.ts`
- `/Users/bhelmkamp/p/badlogic/pi-mono/packages/coding-agent/src/core/agent-session.ts`
- `/Users/bhelmkamp/p/badlogic/pi-mono/packages/coding-agent/docs/json.md`
## Cross-System Patterns
### Patterns worth copying
- One obvious discriminator per public event.
- Explicit unions for error state, stop reason, retry state, and requires-action state.
- Stable correlation IDs for session/thread/turn/message/tool levels.
- A documented replay story for long-running streams.
- Generated public schemas from one source of truth.
- A clear distinction between snapshot events and delta events.
### Patterns worth avoiding
- Opaque `unknown` payloads inside otherwise typed events.
- Server-injected fields that the public schema does not model.
- Mixing keep-alives, control RPCs, and domain events in one event contract.
- Event systems that only make sense in the context of one transport, for example JSON-RPC `method`/`params`, when the real need is a transport-agnostic event log.
- No universal ID or timestamp on durable events.
## Recommendations For Fabro
### 1. Keep the canonical envelope
Fabro should keep `id`, `ts`, `run_id`, `event`, `session_id`, `parent_session_id`, `node_id`, and `node_label` exactly as the backbone of the public schema. That is already better than every comparator except Claude Sessions on consistency.
### 2. Do not let transport metadata leak informally
If Fabro supports SSE replay or live reattach, define transport rules explicitly:
- SSE `id`
- replay cursor semantics
- comment heartbeat vs payload heartbeat
- reconnect guarantees
Do not make clients depend on extra fields injected by one server path that are absent from the formal schema.
### 3. Add deeper correlation IDs where the product needs them
Fabro already has run/session/node metadata. The next likely additions are:
- `turn_id`
- `message_id`
- `tool_call_id`
- `request_id`
- `attempt`
Those should be explicit schema fields, not encoded into ad hoc strings.
### 4. Prefer unions over stringly terminal state
If Fabro expands live session or agent events, model terminal and waiting states like Claude Sessions does:
- `stop_reason`
- `retry_status`
- `requires_action`
- `error_kind`
Avoid free-form strings when a small tagged union will do.
### 5. Standardize lifecycle families
If Fabro emits live agent output, choose one lifecycle grammar and document it:
- `.started`
- `.delta`
- `.completed`
- `.failed`
If snapshot replacement events also exist, mark them clearly and document when clients should treat them as authoritative replacement vs append-only updates.
### 6. Keep domain events separate from control and keep-alive traffic
Claude Code shows the downside of multiplexing control requests, control responses, keep-alives, and domain messages in one stream contract. Fabro's durable run events should stay product-facing and analyzable.
### 7. Add schema-drift tests for streamed events
OpenCode and Goose both show how easy it is for the wire stream to diverge from the published schema. Fabro should keep tests that validate:
- every emitted streamed payload is representable by the public schema
- no consumer-visible fields are injected outside the schema
- replay/heartbeat frames are documented and tested separately
## Bottom Line
Fabro does not need to copy any one competitor's schema wholesale.
The best composite design is:
- Claude Sessions' explicit unions for status and errors
- Goose's replay semantics
- Codex's separation between a simple high-level client view and a richer low-level view, if Fabro ever needs both
- OpenCode's breadth of runtime events
- pi-mono's streaming lifecycle grammar
- Fabro's existing canonical envelope as the foundation
That combination would produce an event model that is both durable and ergonomic: good for live UI streaming, replay, analytics, tests, and long-term compatibility.

View file

@ -0,0 +1,484 @@
# 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](/Users/bhelmkamp/p/fabro-sh/fabro/docs-internal/fabro-event-schema-v2-proposal.md) for:
- proposal 1: one canonical persisted log, not two truths
- proposal 2: formalize and generalize the existing `since_seq` replay 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
- `RunEvent` remains the canonical semantic event type
- `seq` remains outside `RunEvent`, in the store/API envelope
- replay stays built around ordered `since_seq` cursors
- typed Rust consumers matching on `EventBody` remain 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 `seq` outside 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_id` in the envelope for concrete stage execution identity
- `parallel_group_id` in the envelope for one execution of a parallel node
- `parallel_branch_id` in the envelope for one branch inside a parallel execution
- `tool_call_id` in 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>` to `RunEvent`
- add `parallel_group_id: Option<String>` to `RunEvent`
- add `parallel_branch_id: Option<String>` to `RunEvent`
- add `tool_call_id: Option<String>` to `RunEvent`
- add `actor: Option<ActorRef>` to `RunEvent`
- extend envelope extraction in `stored_event_fields()` to populate the new execution-topology fields when known
- extend envelope extraction in `stored_event_fields()` to populate `tool_call_id` on tool-lifecycle events when known
- update `RunEvent` serialization and parsing so the new optional envelope fields round-trip cleanly
### Keep As-Is
- `RunEvent` remains the canonical semantic event type
- `EventBody` remains the typed tagged union of durable event families
- `EventBody::Unknown` remains the compatibility valve for unknown event names on read
- `EventEnvelope` remains the ordered outer wrapper with `seq` outside the event payload
- `EventEnvelope.payload` remains `EventPayload`, not `RunEvent`
- the internal/store `EventEnvelope` Rust 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 `seq` into `RunEvent`
- do not introduce `entity_type`, `entity_id`, or `event_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 `RunEvent`s
## Canonical Rust Shapes
V2 should model the public contract directly as hand-coded Rust types, following the existing architecture.
```rust
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:
```json
{
"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:
```json
{
"event": "stage.completed",
"properties": { "...": "..." }
}
```
V2 should also preserve the current unknown-event fallback shape:
```rust
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`, and `event` are always present on the serialized `RunEvent`.
- `seq` is not part of `RunEvent`. It stays in the outer `EventEnvelope`.
- Optional envelope fields are omitted, never serialized as `null`.
- The existing top-level envelope fields remain:
- `node_id`
- `node_label`
- `session_id`
- `parent_session_id`
- V2 adds only these new optional envelope fields:
- `stage_id`
- `parallel_group_id`
- `parallel_branch_id`
- `tool_call_id`
- Other relationship identifiers stay inside typed `properties`.
- `turn_id` remains in typed `properties`; see the deferral decision in `Capability Coverage Decisions`.
- `actor` is optional. When present, it identifies the primary actor for the event.
- Set `actor` on human- or agent-initiated events where that identity matters to consumers. Example: `run.cancel.requested` should identify the user who initiated the cancel.
- Set `actor` on durable agent output when the producing session identity matters. Example: `agent.message` should identify the agent session.
- Omit `actor` for routine runtime events with no meaningful primary actor. Example: `stage.started`.
### ID Format Conventions
- `run_id` keeps Fabro's current format: an unprefixed ULID string.
- `stage_id` keeps Fabro's current format: `"{node_id}@{visit}"`.
- `node_id` is the stable graph node identifier from the workflow definition.
- `parallel_group_id` should be the durable identity of one execution of a parallel node. The default format should be `"{node_id}@{visit}"`.
- `parallel_branch_id` should 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_id` is present on events tied to a concrete stage execution.
- `parallel_group_id` is present on `parallel.*` events and on events emitted inside a parallel execution when that scope is known.
- `parallel_branch_id` is present on `parallel.branch.*` events and on nested events emitted inside a specific branch when that scope is known.
- `session_id` and `parent_session_id` keep their current meaning for forwarded agent/session activity.
- `tool_call_id` is present on `agent.tool.*` events and on other durable events that directly describe the same tool call.
- `node_label` remains in the envelope for display-oriented consumers.
- `actor` is 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_type`
- `entity_id`
- `event_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 `EventEnvelope` should flatten `seq` into 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.snapshot`
- `session.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.
1. Update [`RunEvent`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-types/src/run_event/mod.rs) to add:
- `stage_id`
- `parallel_group_id`
- `parallel_branch_id`
- `tool_call_id`
- `actor`
2. Update `RunEvent::to_value()` and `RunEvent` parsing in [`run_event/mod.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-types/src/run_event/mod.rs) so the new envelope fields serialize and deserialize.
3. Extend `StoredEventFields` and `stored_event_fields()` in [`event.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-workflow/src/event.rs) to populate:
- `stage_id`
- `parallel_group_id`
- `parallel_branch_id`
- `tool_call_id` on tool-lifecycle events
- `actor` when 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 for `tool_call_id`.
4. Leave [`EventEnvelope`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-store/src/types.rs) structurally unchanged:
- `seq: u32`
- `payload: EventPayload`
5. Update API/SSE envelope serialization so wire JSON is flattened:
- top-level `seq`
- then the `RunEvent` payload fields alongside it
- no `"payload": { ... }` wrapper in JSON responses
6. Leave the replay/attach flow unchanged in behavior:
- persisted replay from `since_seq`
- live tail after replay
- no snapshots
7. Keep the current `EventBody` family surface unless there is an explicit product reason to change a specific family.
8. Keep streaming-noise agent events out of durable `RunEvent` conversion.
9. Update the HTTP/API schema docs to reflect both:
- new `RunEvent` envelope fields
- flattened JSON serialization of `EventEnvelope`
## EventBody And Property Model
V2 should keep the current hand-coded domain split for prop structs:
- run props in [`run.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-types/src/run_event/run.rs)
- stage and checkpoint props in [`stage.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-types/src/run_event/stage.rs)
- agent props in [`agent.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-types/src/run_event/agent.rs)
- infra/setup/devcontainer props in [`infra.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-types/src/run_event/infra.rs)
- parallel/interview/git/misc props in [`misc.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-types/src/run_event/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.created`
- `run.started`
- `run.submitted`
- `run.starting`
- `run.running`
- `run.removing`
- `run.cancel.requested`
- `run.pause.requested`
- `run.unpause.requested`
- `run.paused`
- `run.unpaused`
- `run.rewound`
- `run.completed`
- `run.failed`
- `run.notice`
### Stage And Prompt
- `stage.started`
- `stage.completed`
- `stage.failed`
- `stage.retrying`
- `stage.prompt`
- `prompt.completed`
### Parallel
- `parallel.started`
- `parallel.branch.started`
- `parallel.branch.completed`
- `parallel.completed`
### Interview / Human Input
- `interview.started`
- `interview.completed`
- `interview.timeout`
- `interview.interrupted`
### Checkpoint
- `checkpoint.completed`
- `checkpoint.failed`
### Agent Durable Events
- `agent.session.started`
- `agent.session.ended`
- `agent.processing.end`
- `agent.input`
- `agent.message`
- `agent.tool.started`
- `agent.tool.completed`
- `agent.error`
- `agent.warning`
- `agent.loop.detected`
- `agent.turn.limit`
- `agent.steering.injected`
- `agent.compaction.started`
- `agent.compaction.completed`
- `agent.llm.retry`
- `agent.sub.spawned`
- `agent.sub.completed`
- `agent.sub.failed`
- `agent.sub.closed`
- `agent.mcp.ready`
- `agent.mcp.failed`
- `agent.failover`
### Git
- `git.commit`
- `git.push`
- `git.branch`
- `git.worktree.added`
- `git.worktree.removed`
- `git.fetch`
- `git.reset`
### Infra And Execution
- `sandbox.*`
- `setup.*`
- `cli.ensure.*`
- `command.*`
- `agent.cli.*`
- `devcontainer.*`
- `pull_request.*`
- `artifact.captured`
- `ssh.ready`
- `subgraph.*`
- `edge.selected`
- `loop.restart`
- `retro.*`
## 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.start`
- `agent.output.replace`
- `agent.text.delta`
- `agent.reasoning.delta`
- `agent.tool.output.delta`
- `agent.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
```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 `Event` to external `RunEvent`.
- Keep `RunEvent` semantic and typed. Do not turn it into a generic reducer envelope.
- Keep `seq` outside the event payload.
- Widen the envelope only modestly: `stage_id`, `parallel_group_id`, `parallel_branch_id`, and `tool_call_id`.
- Keep `session_id` as the existing top-level session field.
- Keep event-specific detail inside typed props.
- Preserve `EventBody::Unknown` as the compatibility valve for unknown event names on read.
- Do not store token deltas or other live UI noise as durable `RunEvent`s.
- 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
## Open Follow-Up
- `correlation_id`-style cross-entity grouping remains deferred until Fabro has a concrete consumer and explicit propagation rules

View file

@ -0,0 +1,633 @@
# Fabro Event Schema V2 Proposal
Date: 2026-04-08
Status: proposal
Assumptions:
- greenfield redesign
- no production deployments
- no backward-compatibility constraints
- optimize for the best long-term public event contract
This proposal turns the earlier ideation into concrete schema changes.
## Design Goal
Fabro should expose:
1. a durable, append-only event log for audit, storage, replay, and projections
2. a separate live stream for UI-oriented snapshots, deltas, and fast progress
They should share IDs and correlation fields, but they should not be the same contract.
## Top 10 Concrete Improvements
### 1. Split the single event story into two concrete public APIs
#### Proposal
Introduce two top-level event contracts:
- `DurableEvent`
- `LiveEvent`
Endpoints:
- `GET /runs/:run_id/events`
- append-only durable events
- replayable
- no keep-alive payload events
- `GET /runs/:run_id/live`
- live UI stream
- snapshots + deltas + keep-alives
- resumable with cursor
#### Durable event shape
```json
{
"kind": "durable",
"id": "evt_01960d0c...",
"seq": 182,
"ts": "2026-04-08T15:01:02.123Z",
"run_id": "run_01JQ...",
"event": "agent.tool.started",
"session_id": "ses_123",
"node_id": "code",
"properties": {
"tool_call_id": "tool_abc",
"tool_name": "read_file",
"arguments": { "path": "src/main.rs" }
}
}
```
#### Live event shape
```json
{
"kind": "live",
"id": "levt_01960d0d...",
"seq": 991,
"ts": "2026-04-08T15:01:03.000Z",
"run_id": "run_01JQ...",
"event": "message.delta",
"session_id": "ses_123",
"message_id": "msg_456",
"part_id": "part_1",
"properties": {
"block_type": "text",
"delta": "Let me check that file..."
}
}
```
#### Why this is better
- Durable events stay stable and analyzable.
- Live events can be noisy and UI-oriented without polluting projections.
- Keeps Fabro from repeating the Claude Code / OpenCode problem of mixing control, transport, and product semantics.
### 2. Add explicit stream ordering, replay, and recovery semantics
#### Proposal
Every durable and live stream event gets:
- `seq: u64`
- SSE `id:` = `seq`
- replay semantics based on `Last-Event-ID`
Server rules:
- if `Last-Event-ID` is present and still buffered, replay `seq > cursor`
- if cursor is too old, return a structured reset event in live streams and `409 replay_reset_required` in durable streams
- durable streams never emit synthetic snapshots
- live streams may start with a `*.snapshot` event after reconnect
#### New live-only events
- `stream.heartbeat`
- `run.snapshot`
- `session.snapshot`
- `node.snapshot`
- `stream.reset_required`
#### Example `stream.reset_required`
```json
{
"kind": "live",
"id": "levt_01960d0e...",
"seq": 1200,
"ts": "2026-04-08T15:02:00.000Z",
"run_id": "run_01JQ...",
"event": "stream.reset_required",
"properties": {
"reason": "cursor_too_old",
"expected_from_seq": 1170
}
}
```
#### Why this is better
- Reattach behavior becomes deterministic.
- Clients no longer guess whether they missed data.
- Replay is part of the contract, not an implementation detail.
### 3. Expand the envelope into a first-class correlation model
#### Proposal
Extend the shared envelope with these optional fields:
- `workflow_id`
- `stage_id`
- `branch_id`
- `checkpoint_id`
- `session_id`
- `parent_session_id`
- `turn_id`
- `message_id`
- `part_id`
- `tool_call_id`
- `request_id`
- `causation_id`
- `correlation_id`
Rules:
- `id` is the event's own identity
- `causation_id` points to the immediate triggering event, if any
- `correlation_id` groups a whole logical operation, for example one user request or one retry attempt tree
- `request_id` is transport/API request scoped, not workflow scoped
#### Concrete change
Move these IDs out of ad hoc `properties` payloads when they are structural identifiers.
Good:
```json
{
"event": "agent.tool.completed",
"tool_call_id": "tool_abc",
"message_id": "msg_456",
"properties": {
"tool_name": "read_file",
"is_error": false
}
}
```
Bad:
```json
{
"event": "agent.tool.completed",
"properties": {
"tool_call_id": "tool_abc",
"message_id": "msg_456",
"tool_name": "read_file"
}
}
```
#### Why this is better
- Correlation becomes universal instead of event-family-specific.
- UI and analytics consumers can join without parsing `properties`.
- Parent/child agent and retry trees become much easier to reason about.
### 4. Replace stringly state with concrete tagged unions
#### Proposal
Define explicit union types for stateful fields.
Examples:
```ts
type StopReason =
| { type: "completed" }
| { type: "requires_input"; request_id: string }
| { type: "interrupted"; interrupt_reason: InterruptReason }
| { type: "failed"; error_kind: ErrorKind }
| { type: "retries_exhausted"; attempts: number };
type RetryStatus =
| { type: "not_retrying" }
| { type: "retry_scheduled"; attempt: number; next_retry_at: string }
| { type: "retrying"; attempt: number }
| { type: "retries_exhausted"; attempts: number };
type ApprovalStatus =
| { type: "not_required" }
| { type: "requested"; approval_id: string }
| { type: "approved"; approval_id: string; actor: string }
| { type: "denied"; approval_id: string; actor: string; reason?: string };
```
#### Concrete fields to replace
- `status`
- `reason`
- `failure_class`
- `interrupt_reason`
- `stop_reason`
- `approval_status`
#### Why this is better
- Eliminates string drift.
- Makes reducers and policy engines much safer.
- Makes test fixtures much more stable.
### 5. Standardize event family grammar across the entire product
#### Proposal
Use one lifecycle vocabulary:
- `.created`
- `.started`
- `.snapshot`
- `.delta`
- `.updated`
- `.completed`
- `.failed`
- `.cancelled`
- `.interrupted`
- `.deleted`
Apply it consistently to the same kinds of things:
- `run.*`
- `stage.*`
- `session.*`
- `turn.*`
- `message.*`
- `message.part.*`
- `tool.*`
- `command.*`
- `checkpoint.*`
- `parallel.branch.*`
- `retro.*`
#### Concrete renames
Current style is already decent, but V2 should be stricter.
Examples:
- `agent.output.start` -> `message.part.started`
- `agent.text.delta` -> `message.part.delta`
- `agent.tool.output.delta` -> `tool.output.delta`
- `agent.processing.end` -> `turn.completed` or `session.idle`, depending on actual semantics
#### Why this is better
- Consumers can infer behavior from naming alone.
- Reduces one-off event families that encode bespoke lifecycle semantics.
### 6. Introduce typed content blocks and block-level deltas
#### Proposal
Represent streamable content as typed message parts.
Base union:
```ts
type MessagePart =
| { type: "text"; part_id: string; text: string }
| { type: "reasoning"; part_id: string; text: string }
| { type: "tool_call"; part_id: string; tool_call_id: string; tool_name: string; input: unknown }
| { type: "tool_result"; part_id: string; tool_call_id: string; output: unknown; is_error: boolean }
| { type: "patch"; part_id: string; patch_ref: string }
| { type: "file_ref"; part_id: string; file_id: string; path: string }
| { type: "artifact_ref"; part_id: string; artifact_id: string; label: string }
| { type: "plan"; part_id: string; items: PlanItem[] }
| { type: "todo"; part_id: string; items: TodoItem[] }
| { type: "command_output"; part_id: string; command_id: string; stream: "stdout" | "stderr"; text: string };
```
Live delta event:
```json
{
"event": "message.part.delta",
"message_id": "msg_456",
"part_id": "part_1",
"properties": {
"part_type": "text",
"delta": "checking src/main.rs"
}
}
```
Durable completion event:
```json
{
"event": "message.completed",
"message_id": "msg_456",
"properties": {
"parts": [
{ "type": "text", "part_id": "part_1", "text": "checking src/main.rs" }
]
}
}
```
#### Why this is better
- Supports rich UI without reparsing free-form text.
- Supports structured summarization, compaction, and retro generation.
- Aligns Fabro with the best parts of Claude Sessions and pi-mono.
### 7. Make approvals, questions, and operator interventions first-class durable events
#### Proposal
Add explicit event families:
- `approval.requested`
- `approval.responded`
- `question.asked`
- `question.answered`
- `interrupt.requested`
- `interrupt.applied`
- `resume.required`
- `resume.applied`
#### Example `approval.requested`
```json
{
"kind": "durable",
"id": "evt_01960d0f...",
"seq": 201,
"ts": "2026-04-08T15:03:00.000Z",
"run_id": "run_01JQ...",
"session_id": "ses_123",
"tool_call_id": "tool_abc",
"event": "approval.requested",
"properties": {
"approval_id": "apr_1",
"scope": "tool_call",
"tool_name": "exec_command",
"request": {
"cmd": "git push origin branch"
}
}
}
```
#### Example `approval.responded`
```json
{
"kind": "durable",
"id": "evt_01960d10...",
"seq": 202,
"ts": "2026-04-08T15:03:10.000Z",
"run_id": "run_01JQ...",
"event": "approval.responded",
"properties": {
"approval_id": "apr_1",
"result": {
"type": "approved",
"actor": "user"
}
}
}
```
#### Why this is better
- Human-in-loop behavior becomes queryable and replayable.
- Workflow interruption is no longer hidden in transport or UI state.
### 8. Add real snapshot events instead of relying on ad hoc reconstruction
#### Proposal
Define explicit snapshot events for live attach and projection recovery:
- `run.snapshot`
- `session.snapshot`
- `node.snapshot`
- `checkpoint.saved`
#### Example `session.snapshot`
```json
{
"kind": "live",
"id": "levt_01960d11...",
"seq": 1500,
"ts": "2026-04-08T15:04:00.000Z",
"run_id": "run_01JQ...",
"session_id": "ses_123",
"event": "session.snapshot",
"properties": {
"state": { "type": "running" },
"turn_id": "turn_9",
"messages": [
{
"message_id": "msg_456",
"role": "assistant",
"parts": [
{ "type": "text", "part_id": "part_1", "text": "checking src/main.rs" }
]
}
],
"active_tool_calls": [
{
"tool_call_id": "tool_abc",
"tool_name": "read_file",
"status": "running"
}
]
}
}
```
#### Concrete rule
- snapshots are authoritative replacement state for live consumers
- snapshots are optional in durable streams
- checkpoints are durable domain snapshots, not just UI snapshots
#### Why this is better
- Fast attach becomes trivial.
- Projections can self-heal from snapshots.
- Checkpoint semantics become explicit rather than emergent.
### 9. Make model, tool, command, and MCP work first-class span families
#### Proposal
Create event families with shared semantics:
- `model.request.started`
- `model.request.completed`
- `model.request.failed`
- `tool.started`
- `tool.output.delta`
- `tool.completed`
- `tool.failed`
- `command.started`
- `command.output.delta`
- `command.completed`
- `command.failed`
- `mcp.call.started`
- `mcp.call.progress`
- `mcp.call.completed`
- `mcp.call.failed`
#### Example `model.request.completed`
```json
{
"kind": "durable",
"id": "evt_01960d12...",
"seq": 220,
"ts": "2026-04-08T15:05:00.000Z",
"run_id": "run_01JQ...",
"session_id": "ses_123",
"turn_id": "turn_9",
"request_id": "req_llm_1",
"event": "model.request.completed",
"properties": {
"provider": "anthropic",
"model": "claude-sonnet-4",
"latency_ms": 1834,
"usage": {
"input_tokens": 1400,
"output_tokens": 380,
"reasoning_tokens": 120,
"cache_read_tokens": 900,
"cache_write_tokens": 0
},
"retry_status": { "type": "not_retrying" }
}
}
```
#### Why this is better
- Cost and latency analysis become first-class.
- Policy engines can reason about real operations, not just stage summaries.
- Cross-provider comparison gets much easier.
### 10. Generate and enforce the public schema, docs, and examples from one registry
#### Proposal
Build a single `event_schema_registry` source that defines:
- envelope fields
- event families
- payload types
- union types
- versioning
- example payloads
Artifacts generated from it:
- Rust types
- TypeScript types
- JSON Schema
- OpenAPI / SSE docs
- sample event fixtures
- validation tests
#### Concrete rules
- every public event must have:
- one schema definition
- one example payload
- one validation test
- no endpoint may inject extra consumer-visible fields outside the schema
- keep-alive frames are documented separately from payload events
#### Why this is better
- Prevents the OpenCode and Goose class of drift.
- Makes Fabro's event API publishable and stable from day one.
## Recommended V2 Event Families
If Fabro were starting from scratch, I would structure the public families like this:
- `run.*`
- `stage.*`
- `checkpoint.*`
- `parallel.branch.*`
- `session.*`
- `turn.*`
- `message.*`
- `message.part.*`
- `model.request.*`
- `tool.*`
- `command.*`
- `mcp.call.*`
- `approval.*`
- `question.*`
- `interrupt.*`
- `resume.*`
- `compaction.*`
- `retro.*`
- `artifact.*`
- `stream.*` (live only)
## Recommended Field Placement Rules
Top-level envelope:
- identity and correlation
- ordering
- timestamps
- scope
`properties`:
- event-family-specific payload
- business data
- structured state payloads
Never in `properties` if they are structural:
- `run_id`
- `seq`
- `event`
- `session_id`
- `message_id`
- `tool_call_id`
- `request_id`
- `causation_id`
- `correlation_id`
## Bottom Line
The best greenfield version of Fabro is not "the current schema plus more events."
It is:
- separate durable and live contracts
- replayable ordered streams
- a richer envelope
- typed state unions
- typed content blocks
- explicit snapshots
- first-class HITL events
- first-class span families
- generated schema/docs/tests from one registry
That would give Fabro a better event platform than any of the compared systems.

View file

@ -0,0 +1,134 @@
---
date: 2026-04-08
topic: fabro-event-schema
focus: greenfield redesign of Fabro's event schemas and streaming contract
---
# Ideation: Fabro Event Schema Redesign
Assumption: greenfield reset. No production deployments, no backward-compatibility constraints, optimize for the best long-term event model rather than incremental migration cost.
These are not ten unrelated features. They are the ten strongest changes to make as one coherent event-platform redesign.
## Codebase Context
- Fabro already has the strongest core envelope in the comparison set: `id`, `ts`, `run_id`, `event`, optional `session_id`, `parent_session_id`, `node_id`, `node_label`, plus `properties`
- `docs-internal/events-strategy.md` already treats events as the durable audit trail that powers storage, SSE, CLI progress, retro analysis, and JSONL sinks
- `RunEvent` and `EventBody` already give Fabro a typed internal model, but the public contract is still more convention-driven than schema-first
- Recent internal plans already push Fabro toward events as source of truth, checkpoint derivation, and simplified `RunEvent`, so the repo is directionally aligned with a stronger event platform
- The biggest remaining gaps are cross-cutting ones: replay, correlation depth, public schema generation, live-stream semantics, and structured status/error/approval states
## Ranked Ideas
### 1. Split Fabro into two event products: a durable event log and a live UI stream
**Description:** Define two first-class streams instead of one overloaded one. The durable log contains immutable domain facts suitable for audit, replay, storage, and projections. The live stream contains UI-oriented deltas, snapshots, keep-alives, and fast-changing progress. They share IDs and correlation fields, but they are different contracts.
**Rationale:** This is the highest-leverage fix. Most competitors get into trouble by mixing audit events, high-frequency streaming deltas, control frames, and reconnect machinery into one schema. Fabro should not. Durable events should be boring and trustworthy. Live events should be optimized for interactivity.
**Downsides:** Two contracts are more work than one. Emitters must decide whether an event is durable, live-only, or both.
**Confidence:** 98%
**Complexity:** High
**Status:** Recommended
### 2. Add a formal replay and resume contract with ordered cursors and snapshot handshakes
**Description:** Every run and session stream gets a monotonic `seq` plus a documented resume protocol: subscribe from cursor, receive the latest snapshot if needed, then apply deltas after `seq > cursor`. Define SSE `id` semantics, dedupe rules, replay buffer guarantees, and failure behavior when a client falls too far behind.
**Rationale:** Goose is the clearest proof that reconnect semantics need to be part of the design, not a side effect. Greenfield Fabro should make "reattach to a long-running workflow" a first-class use case.
**Downsides:** The server needs replay buffers or snapshot storage, and clients need to implement cursor logic correctly.
**Confidence:** 96%
**Complexity:** High
**Status:** Recommended
### 3. Expand the envelope into a real correlation graph
**Description:** Keep the existing strong envelope and add the IDs Fabro will actually want long term: `workflow_id`, `branch_id`, `checkpoint_id`, `turn_id`, `message_id`, `tool_call_id`, `request_id`, `causation_id`, and `correlation_id`. Not every event sets every field, but the contract makes those slots explicit.
**Rationale:** Current Fabro is strong at run/session/node identity, but still thin below that. The next generation of debugging, UI, projection, and analytics work will want to join events by turn, message, tool call, branch, checkpoint, and request without reconstructing those edges from payloads.
**Downsides:** Emitters and handlers must be stricter about ID ownership and propagation.
**Confidence:** 95%
**Complexity:** Medium
**Status:** Recommended
### 4. Make the public event contract schema-first and generated from one registry
**Description:** Keep Fabro's `event` + event-specific payload shape, but stop treating the public wire contract as implicit. Generate JSON Schema, TypeScript types, Rust validators, docs, and streaming examples from one source of truth. Every public event family gets an explicit schema version from day one.
**Rationale:** This is the cleanest fix for drift. Claude Sessions benefits from having a clear public union. OpenCode shows how useful generated event types are. Fabro should combine both while preserving its stronger envelope.
**Downsides:** Codegen and schema governance add process overhead. Some engineers will fight the discipline.
**Confidence:** 94%
**Complexity:** High
**Status:** Recommended
### 5. Normalize lifecycle grammar across all streamable entities
**Description:** Standardize event families around a small lifecycle vocabulary: `.started`, `.delta`, `.snapshot`, `.completed`, `.failed`, `.cancelled`, `.interrupted`. Apply it consistently to runs, stages, sessions, turns, messages, tool calls, commands, checkpoints, parallel branches, and retro work where relevant.
**Rationale:** pi-mono is strongest here. Clients get dramatically simpler when every streamable thing follows the same lifecycle rules instead of bespoke one-off semantics.
**Downsides:** Some event families will feel slightly unnatural if forced into the same lifecycle vocabulary. Discipline matters.
**Confidence:** 93%
**Complexity:** Medium
**Status:** Recommended
### 6. Replace stringly status and failure fields with explicit tagged unions
**Description:** Stop representing terminal and waiting states as loosely-typed strings where possible. Add typed unions for `stop_reason`, `retry_status`, `approval_status`, `wait_reason`, `error_kind`, `failure_class`, and `interrupt_reason`. Preserve display strings separately when useful.
**Rationale:** Claude Sessions gets this exactly right. This is a major quality jump for policy engines, UIs, analytics, and test fixtures. It also reduces accidental schema drift where one code path emits `"timed_out"` and another emits `"timeout"`.
**Downsides:** More up-front schema design. Adding new states later requires more care.
**Confidence:** 96%
**Complexity:** Medium
**Status:** Recommended
### 7. Introduce typed content blocks and block-level deltas for agent output
**Description:** Model agent-facing content as typed blocks instead of mostly strings: `text`, `reasoning`, `tool_call`, `tool_result`, `patch`, `file_ref`, `artifact_ref`, `plan`, `todo`, `command_output`, and `summary`. Live deltas target block IDs rather than appending ambiguous raw text.
**Rationale:** This is the difference between a transcript that only humans can read and one that both humans and tools can reason over. It unlocks richer UIs, selective re-rendering, better retro analysis, and much cleaner summarization and compaction.
**Downsides:** The model is more complex than "event name plus string payload." Poorly chosen block boundaries can make clients awkward.
**Confidence:** 92%
**Complexity:** High
**Status:** Recommended
### 8. Make human-in-the-loop and control-plane semantics first-class events
**Description:** Promote approvals, questions, interrupts, resumes, compactions, and operator interventions into explicit event families: `approval.requested`, `approval.responded`, `question.asked`, `question.answered`, `interrupt.requested`, `interrupt.applied`, `resume.required`, `compaction.started`, `compaction.completed`, `compaction.failed`.
**Rationale:** This is one of the clearest wins from Claude Sessions and OpenCode. Human-in-loop behavior is not edge-case control traffic. It is core workflow state and deserves durable, typed representation.
**Downsides:** It increases surface area. Some flows that are currently implicit must become explicit state machines.
**Confidence:** 94%
**Complexity:** Medium
**Status:** Recommended
### 9. Add first-class snapshot events for fast attach and projection repair
**Description:** Introduce self-contained snapshot events such as `run.snapshot`, `session.snapshot`, and `checkpoint.saved` that intentionally duplicate enough state to let clients and projectors reattach without replaying the full history. Treat these as part of the contract, not ad hoc recovery hacks.
**Rationale:** This complements replay. Durable facts remain append-only, but long-running workflows need efficient recovery points. Greenfield Fabro can design snapshots deliberately instead of letting checkpoint semantics and UI recovery drift apart.
**Downsides:** Snapshot compaction and retention rules must be explicit or the event system becomes harder to reason about.
**Confidence:** 90%
**Complexity:** High
**Status:** Recommended
### 10. Promote model, tool, and command work into first-class span-style event families
**Description:** Treat model requests, tool execution, MCP calls, shell commands, and patch application as first-class event families with started/completed/error plus usage, latency, retries, provider, model, routing, approval outcome, and output references. Do not hide these behind generic stage completion summaries.
**Rationale:** Fabro is an AI workflow product. The event model should expose the actual unit economics and failure surfaces of AI work. This gives better debugging, cost analysis, policy enforcement, and product telemetry than aggregating everything back into stage summaries.
**Downsides:** More event volume. Care is needed to keep live deltas separate from durable summaries.
**Confidence:** 93%
**Complexity:** Medium
**Status:** Recommended
## What This Adds Up To
If Fabro adopted all ten, the resulting idealized model would look like this:
- one durable event log for facts
- one live stream for interactive state
- one shared envelope with strong correlation IDs
- one generated public schema registry
- one consistent lifecycle grammar
- one explicit replay/snapshot story
- typed blocks and typed states instead of strings and ad hoc payloads
That is materially better than any single comparator repo.
## Rejection Summary
| # | Idea | Reason Rejected |
|---|------|-----------------|
| 1 | Keep one stream and just document it better | Not enough — durable and live concerns have different optimization goals |
| 2 | Flatten all event-specific fields into the top level | Root churn would make the schema worse, not better |
| 3 | Switch to JSON-RPC-style `method`/`params` notifications | Too transport-shaped for Fabro's broader event-log use case |
| 4 | Use timestamps alone for replay ordering | Weak contract; reconnect needs explicit sequence semantics |
| 5 | Eventize binary artifacts and all large blobs directly | Expensive and noisy; use refs/metadata instead |
| 6 | Keep string errors but standardize message text | Still not machine-readable enough |
| 7 | Make snapshots the only source of truth | Loses auditability and event-sourced advantages |
| 8 | Encode every UI concern in the durable log | Durable logs should stay trustworthy and projection-friendly |
| 9 | Preserve current schema and only add more event names | Misses the deeper contract problems |
| 10 | Treat approvals and questions as transport-level control traffic | These are product-level workflow semantics and belong in the event model |
## Session Log
- 2026-04-08: Grounded ideation from current Fabro event docs and code, plus comparison against Claude Sessions, Claude Code, Goose, OpenAI Codex, OpenCode, and pi-mono. Survivors intentionally optimized for greenfield quality rather than migration ease.