fabro/docs/internal/fabro-event-schema-v2-proposal.md
Bryan Helmkamp 283eab181f
refactor(docs): split docs/ into public/ and internal/
Invert the docs convention so the Mintlify-published site lives under
docs/public/ and internal artifacts (strategy docs, brainstorms, plans,
etc.) sit at docs/ root or docs/internal/. Tools that default to writing
into docs/ now land in the catch-all instead of leaking into the
published tree.

- Move Mintlify content (administration/, agents/, api-reference/,
  changelog/, core-concepts/, examples/, execution/, getting-started/,
  human-tools/, integrations/, languages/, reference/, tutorials/,
  workflows/, images/, logo/, docs.json, favicon.svg, dot-highlight.js)
  into docs/public/.
- Collapse docs-internal/ into docs/internal/.
- Update Rust path references (fabro-api/build.rs, fabro-server,
  fabro-dev), TypeScript generator arg, CI path filters, clippy.toml
  reasons, AGENTS.md/CLAUDE.md, and README.md image refs.

Mintlify dashboard project root must be updated to docs/public/ in a
follow-up. .mintignore move/trim and .claude/skills/ updates land in a
separate commit.
2026-04-27 07:21:13 -07:00

14 KiB

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

{
  "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

{
  "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

{
  "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:

{
  "event": "agent.tool.completed",
  "tool_call_id": "tool_abc",
  "message_id": "msg_456",
  "properties": {
    "tool_name": "read_file",
    "is_error": false
  }
}

Bad:

{
  "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:

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:

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:

{
  "event": "message.part.delta",
  "message_id": "msg_456",
  "part_id": "part_1",
  "properties": {
    "part_type": "text",
    "delta": "checking src/main.rs"
  }
}

Durable completion event:

{
  "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

{
  "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

{
  "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

{
  "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

{
  "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.

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)

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.