mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-10 03:30:59 +00:00
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.
633 lines
14 KiB
Markdown
633 lines
14 KiB
Markdown
# 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.
|