Skip to content

Commit 42e3df1

Browse files
committed
feat(observability): JSON Schema wire validator (v0.6 #1)
Lands docs/event-schema.json as the single source of truth for the JSONL wire format between the TS Bridge (producer) and the Rust Transport (consumer). Both sides now validate every event at the boundary. What the schema covers: - Well-typed variants matching inspector/src/event.rs: runtime.metrics, tool.call, hydra.veto, pech.ledger, task.updated, code.modified, request.approval (v0.5 control inbound). - All 28 GenericPayload variants via a generic-branch oneOf. - Outbound control commands (control.command / approval.response) under a separate top-level oneOf (validateCommand on TS). What it does NOT enforce: - Tight `additionalProperties` — every variant is open so unknown fields on a known type round-trip without rejection. Wire-shape drift fails closed; field-addition stays loose. - Nested payload structure beyond pech.ledger (kept open by design — see ToolCallPayload / GenericPayload.extra in event.rs). Drop-on-mismatch contract: - Bridge.enqueue (TS): validates toWireRecord(event) before write; on failure logs via onError and drops the event. The wire never carries invalid JSON. - forward_lines (Rust): validates after parse_line succeeds; on failure logs at warn level and skips the event. One bad line never crashes the consumer. No new top-level deps either side. The validator is a hand-rolled ~150 LOC walker on each side that supports exactly the JSON Schema keywords this schema uses (type, properties, required, oneOf, enum, const, minimum, \$ref, additionalProperties). Identical heuristics on both sides for choosing which oneOf-branch failure to surface (prefer the branch whose `type` discriminator matched the input). Tests: - TS: tests/observability/schema.test.ts — 20 new unit cases including a fixture-by-fixture regression on bridge-roundtrip.jsonl. - Rust: inspector/tests/schema.rs — 14 mirror cases. Plus 4 in-module unit tests.
1 parent 9e1154f commit 42e3df1

9 files changed

Lines changed: 1334 additions & 10 deletions

File tree

docs/event-schema.json

Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
{
2+
"$schema": "https://json-schema.org/draft/2020-12/schema",
3+
"$id": "https://enchanter.ai/schemas/event-schema.json",
4+
"title": "Enchanter event-bus wire format",
5+
"description": "Single source of truth for the JSONL events flowing between the TypeScript runtime (producer, src/observability/bridge.ts) and the Rust inspector (consumer, inspector/src/transport.rs). Mirrors the Rust enum in inspector/src/event.rs. Both sides validate every line against this schema and drop on mismatch with a logged warning. See docs/event-schema.md for prose context.",
6+
"definitions": {
7+
"phase": {
8+
"type": "string",
9+
"enum": [
10+
"anchor",
11+
"trust-gate",
12+
"pre-dispatch",
13+
"dispatch",
14+
"post-response",
15+
"post-session",
16+
"cross-session"
17+
]
18+
},
19+
"severity": {
20+
"type": "string",
21+
"enum": ["debug", "info", "warning", "high", "critical"]
22+
},
23+
"time": {
24+
"type": "number",
25+
"minimum": 0
26+
},
27+
"genericVariant": {
28+
"description": "Loose-shape variants flowing through Rust GenericPayload. Required: type, time. Optional common fields are recognized; unknown fields round-trip via additionalProperties:true.",
29+
"type": "object",
30+
"required": ["type", "time"],
31+
"properties": {
32+
"type": {
33+
"type": "string",
34+
"enum": [
35+
"session.started",
36+
"session.opened",
37+
"session.closed",
38+
"session.ended",
39+
"phase.entered",
40+
"phase.completed",
41+
"plugin.loaded",
42+
"plugin.health",
43+
"tool.result",
44+
"tool.error",
45+
"sylph.veto",
46+
"crow.trust",
47+
"djinn.anchor",
48+
"djinn.drift",
49+
"gorgon.hotspot",
50+
"naga.spec_check",
51+
"lich.review",
52+
"emu.context_update",
53+
"task.created",
54+
"task.started",
55+
"task.blocked",
56+
"task.completed",
57+
"task.failed",
58+
"code.generated",
59+
"file.created",
60+
"file.modified",
61+
"test.run",
62+
"test.passed",
63+
"test.failed",
64+
"pr.created"
65+
]
66+
},
67+
"time": { "$ref": "#/definitions/time" },
68+
"session_id": { "type": "string" },
69+
"task_id": { "type": "string" },
70+
"plugin": { "type": "string" },
71+
"phase": { "$ref": "#/definitions/phase" },
72+
"severity": { "$ref": "#/definitions/severity" },
73+
"message": { "type": "string" }
74+
},
75+
"additionalProperties": true
76+
}
77+
},
78+
"oneOf": [
79+
{
80+
"title": "runtime.metrics",
81+
"type": "object",
82+
"required": [
83+
"type",
84+
"time",
85+
"open_sessions",
86+
"ongoing_tasks",
87+
"queued_tasks",
88+
"blocked_tasks",
89+
"code_written_lifetime_loc",
90+
"code_modified_lifetime_loc",
91+
"files_created_lifetime",
92+
"files_modified_lifetime",
93+
"tool_calls_lifetime",
94+
"prs_created_lifetime",
95+
"tests_run_lifetime",
96+
"tests_passed_rate",
97+
"total_spend_lifetime"
98+
],
99+
"properties": {
100+
"type": { "const": "runtime.metrics" },
101+
"time": { "$ref": "#/definitions/time" },
102+
"open_sessions": { "type": "number", "minimum": 0 },
103+
"ongoing_tasks": { "type": "number", "minimum": 0 },
104+
"queued_tasks": { "type": "number", "minimum": 0 },
105+
"blocked_tasks": { "type": "number", "minimum": 0 },
106+
"code_written_lifetime_loc": { "type": "number", "minimum": 0 },
107+
"code_modified_lifetime_loc": { "type": "number", "minimum": 0 },
108+
"files_created_lifetime": { "type": "number", "minimum": 0 },
109+
"files_modified_lifetime": { "type": "number", "minimum": 0 },
110+
"tool_calls_lifetime": { "type": "number", "minimum": 0 },
111+
"prs_created_lifetime": { "type": "number", "minimum": 0 },
112+
"tests_run_lifetime": { "type": "number", "minimum": 0 },
113+
"tests_passed_rate": { "type": "number" },
114+
"total_spend_lifetime": { "type": "number" },
115+
"session_id": { "type": "string" },
116+
"plugin": { "type": "string" },
117+
"phase": { "$ref": "#/definitions/phase" },
118+
"message": { "type": "string" }
119+
},
120+
"additionalProperties": true
121+
},
122+
{
123+
"title": "tool.call",
124+
"type": "object",
125+
"required": ["type", "time", "tool", "payload"],
126+
"properties": {
127+
"type": { "const": "tool.call" },
128+
"time": { "$ref": "#/definitions/time" },
129+
"tool": { "type": "string" },
130+
"payload": { "type": "object" },
131+
"session_id": { "type": "string" },
132+
"task_id": { "type": "string" },
133+
"plugin": { "type": "string" },
134+
"phase": { "$ref": "#/definitions/phase" },
135+
"message": { "type": "string" }
136+
},
137+
"additionalProperties": true
138+
},
139+
{
140+
"title": "hydra.veto",
141+
"type": "object",
142+
"required": ["type", "time", "policy", "reason", "action", "severity", "payload"],
143+
"properties": {
144+
"type": { "const": "hydra.veto" },
145+
"time": { "$ref": "#/definitions/time" },
146+
"policy": { "type": "string" },
147+
"reason": { "type": "string" },
148+
"action": { "type": "string" },
149+
"severity": { "$ref": "#/definitions/severity" },
150+
"payload": {},
151+
"session_id": { "type": "string" },
152+
"plugin": { "type": "string" },
153+
"phase": { "$ref": "#/definitions/phase" },
154+
"workspace": { "type": "string" },
155+
"env": { "type": "string" },
156+
"message": { "type": "string" }
157+
},
158+
"additionalProperties": true
159+
},
160+
{
161+
"title": "pech.ledger",
162+
"type": "object",
163+
"required": ["type", "time", "payload"],
164+
"properties": {
165+
"type": { "const": "pech.ledger" },
166+
"time": { "$ref": "#/definitions/time" },
167+
"payload": {
168+
"type": "object",
169+
"required": [
170+
"input_tokens",
171+
"output_tokens",
172+
"cost_usd",
173+
"session_cost_usd",
174+
"daily_cost_usd"
175+
],
176+
"properties": {
177+
"input_tokens": { "type": "number", "minimum": 0 },
178+
"output_tokens": { "type": "number", "minimum": 0 },
179+
"cost_usd": { "type": "number" },
180+
"session_cost_usd": { "type": "number" },
181+
"daily_cost_usd": { "type": "number" }
182+
},
183+
"additionalProperties": true
184+
},
185+
"session_id": { "type": "string" },
186+
"task_id": { "type": "string" },
187+
"plugin": { "type": "string" },
188+
"phase": { "$ref": "#/definitions/phase" },
189+
"message": { "type": "string" }
190+
},
191+
"additionalProperties": true
192+
},
193+
{
194+
"title": "task.updated",
195+
"type": "object",
196+
"required": ["type", "time", "task_id", "session_id", "age_seconds"],
197+
"properties": {
198+
"type": { "const": "task.updated" },
199+
"time": { "$ref": "#/definitions/time" },
200+
"task_id": { "type": "string" },
201+
"session_id": { "type": "string" },
202+
"age_seconds": { "type": "number", "minimum": 0 },
203+
"status": { "type": "string" },
204+
"intent": { "type": "string" },
205+
"file_or_area": { "type": "string" },
206+
"phase": { "$ref": "#/definitions/phase" },
207+
"risk": { "type": "string" },
208+
"plugin": { "type": "string" },
209+
"message": { "type": "string" }
210+
},
211+
"additionalProperties": true
212+
},
213+
{
214+
"title": "code.modified",
215+
"type": "object",
216+
"required": ["type", "time", "file", "lines_added", "lines_removed", "lines_modified"],
217+
"properties": {
218+
"type": { "const": "code.modified" },
219+
"time": { "$ref": "#/definitions/time" },
220+
"file": { "type": "string" },
221+
"language": { "type": "string" },
222+
"lines_added": { "type": "number", "minimum": 0 },
223+
"lines_removed": { "type": "number", "minimum": 0 },
224+
"lines_modified": { "type": "number", "minimum": 0 },
225+
"session_id": { "type": "string" },
226+
"task_id": { "type": "string" },
227+
"plugin": { "type": "string" },
228+
"phase": { "$ref": "#/definitions/phase" },
229+
"message": { "type": "string" }
230+
},
231+
"additionalProperties": true
232+
},
233+
{
234+
"title": "request.approval",
235+
"description": "v0.5 #4 control inbound — runtime asks the inspector for a human verdict.",
236+
"type": "object",
237+
"required": ["type", "time", "correlation_id", "plugin", "reason"],
238+
"properties": {
239+
"type": { "const": "request.approval" },
240+
"time": { "$ref": "#/definitions/time" },
241+
"correlation_id": { "type": "string" },
242+
"plugin": { "type": "string" },
243+
"reason": { "type": "string" },
244+
"phase": { "$ref": "#/definitions/phase" },
245+
"session_id": { "type": "string" },
246+
"payload": {},
247+
"message": { "type": "string" }
248+
},
249+
"additionalProperties": true
250+
},
251+
{ "$ref": "#/definitions/genericVariant" }
252+
],
253+
"outboundCommands": {
254+
"description": "Outbound command shapes flow inspector -> runtime on the same socket. Discriminator is `kind`, not `type`. Validated by validateCommand on the TS side and (currently) parsed via runtime control-protocol on the Rust side.",
255+
"oneOf": [
256+
{
257+
"title": "control.command / approval.response",
258+
"type": "object",
259+
"required": ["kind", "command", "correlation_id", "decision"],
260+
"properties": {
261+
"kind": { "const": "control.command" },
262+
"command": { "const": "approval.response" },
263+
"correlation_id": { "type": "string" },
264+
"decision": { "type": "string", "enum": ["approve", "veto"] },
265+
"reason": { "type": "string" }
266+
},
267+
"additionalProperties": true
268+
}
269+
]
270+
}
271+
}

docs/event-schema.md

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
11
# Enchanter event-schema (JSONL bridge)
22

3-
Single source of truth for the wire format between the TypeScript runtime
4-
(producer) and the Rust inspector (consumer). The Rust side reads JSONL —
5-
one JSON object per line — over stdin, a regular file, or a TCP socket.
6-
This doc pins what the TS bridge (`src/observability/bridge.ts`) writes
7-
and what the Rust transport (`inspector/src/transport.rs`) accepts.
8-
9-
Rust ground truth: `inspector/src/event.rs` — variant tags, required
10-
fields, and the `GenericPayload` fallback are defined there. If this doc
11-
and that file disagree, that file wins; open a PR fixing the doc.
3+
Authoritative machine schema: [`event-schema.json`](./event-schema.json).
4+
Both sides of the wire validate every event against that file at boundary —
5+
the TS Bridge (`src/observability/bridge.ts`) on emit, the Rust Transport
6+
(`inspector/src/transport.rs`) on parse. Validation failures **drop the
7+
event with a logged warning** — no crash on the producer or consumer side.
8+
9+
This Markdown is the prose narrative; if the two ever disagree the JSON
10+
Schema wins for shape, and `inspector/src/event.rs` wins for type-tagged
11+
variant fields. Renames or new variants update all three together
12+
(`event.rs`, `event-schema.json`, this doc) in one PR.
13+
14+
The Rust side reads JSONL — one JSON object per line — over stdin, a
15+
regular file, or a TCP socket. This doc pins what the TS bridge writes
16+
and what the Rust transport accepts.
1217

1318
## Wire envelope
1419

inspector/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
//! - `views` — per-view rendering (overview, plugins, events, ...)
1010
1111
pub mod event;
12+
pub mod schema;
1213
pub mod transport;
1314
pub mod state;
1415
pub mod app;

0 commit comments

Comments
 (0)