Skip to content

Commit 059ed74

Browse files
committed
fix(observability): discriminator-lock the oneOf schema; align cockpit to 0.5.0
Three schema-validator tests were failing because the inbound-event oneOf permitted fall-through: a tool.call with a missing required field, or a runtime.metrics with a bad-type field, would be rescued by the permissive generic branch. Add a branchIsStrictConst() guard and rewrite the loop so once a strict-const branch claims a given type, fall-through to the generic branch is forbidden. Also constrain genericVariant.type to the 30 known Rust Event discriminators. Bump inspector/Cargo.toml 0.1.0 -> 0.5.0 so the Rust cockpit matches the Node SDK version reported by package.json. Cargo.lock regenerated by cargo build --release. Tests: 403 pass, 0 fail (was 400 / 3).
1 parent 660f795 commit 059ed74

4 files changed

Lines changed: 85 additions & 20 deletions

File tree

docs/event-schema.json

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,45 @@
2525
"minimum": 0
2626
},
2727
"genericVariant": {
28-
"description": "Permissive fallback for any event whose type isn't well-typed in the oneOf below. Required: type (any string), time. Both producer (TS) and consumer (Rust) accept any type string here so wire-format extensions don't require a schema bump in lockstep. The Rust GenericPayload mirrors this — unknown discriminators round-trip via the flattened extras map.",
28+
"description": "Fallback for known event types that aren't well-typed in the strict oneOf above. The `type` enum mirrors the Rust Event enum in inspector/src/event.rs exactly — unknown discriminators are rejected on BOTH sides (the Rust enum has no #[serde(other)] catch-all, and this validator now matches). Strict-branch types (runtime.metrics, tool.call, hydra.veto, pech.ledger, task.updated, code.modified, request.approval) are intentionally excluded from this enum: those types must satisfy their strict branch and cannot fall through to the generic permissive shape.",
2929
"type": "object",
3030
"required": ["type", "time"],
3131
"properties": {
32-
"type": { "type": "string" },
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+
},
3367
"time": { "$ref": "#/definitions/time" },
3468
"session_id": { "type": "string" },
3569
"task_id": { "type": "string" },

inspector/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

inspector/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "enchanter-inspector"
3-
version = "0.1.0"
3+
version = "0.5.0"
44
edition = "2021"
55
default-run = "enchanter"
66
description = "Terminal-first TUI cockpit for the Enchanter AI runtime"

src/observability/schema.ts

Lines changed: 47 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,20 @@ function branchTypeMatches(branch: SchemaNode, value: unknown): boolean {
111111
return false;
112112
}
113113

114+
/** Is this branch a strict-discriminator branch (its `type` property pins a
115+
* single `const`)? Used to enforce no-fallthrough: when the input's `type`
116+
* matches a strict branch's const, only that branch may match — we will not
117+
* fall through to the generic permissive variant. Mirrors the Rust
118+
* serde(tag = "type") enum dispatch in inspector/src/event.rs, which has no
119+
* #[serde(other)] catch-all. */
120+
function branchIsStrictConst(branch: SchemaNode): boolean {
121+
const properties = branch.properties as Record<string, SchemaNode> | undefined;
122+
if (!properties) return false;
123+
const typeSchema = properties.type;
124+
if (!typeSchema) return false;
125+
return Object.prototype.hasOwnProperty.call(typeSchema, 'const');
126+
}
127+
114128
function ok(): ValidationResult {
115129
return { ok: true };
116130
}
@@ -184,27 +198,44 @@ function check(node: SchemaNode, value: unknown, path: string[]): ValidationResu
184198
}
185199
}
186200

187-
// oneOf — first match wins. If none match, prefer the failure from the
188-
// branch whose `type` discriminator (const or enum) matched the input —
189-
// that's the branch the producer "intended" to satisfy. Otherwise fall
190-
// back to the deepest-path failure.
201+
// oneOf — discriminator-aware. If a strict-const branch matches the
202+
// input's `type` field, only that branch is allowed: failures there
203+
// do NOT fall through to the generic permissive variant. This mirrors
204+
// the Rust serde(tag = "type") enum dispatch on the consumer side and
205+
// closes a hole where malformed strict events (missing required fields,
206+
// wrong-type values) were silently rescued by the generic branch.
207+
//
208+
// When no strict branch's discriminator matches, the generic branch is
209+
// tried; its own `type` enum decides whether the event is a known generic
210+
// discriminator or an unknown event class that must be rejected.
191211
if (Array.isArray(schema.oneOf)) {
212+
const branches = schema.oneOf as SchemaNode[];
213+
214+
// Look for a strict-const branch whose const matches the input's type.
215+
for (const rawBranch of branches) {
216+
const branch = deref(rawBranch);
217+
if (branchIsStrictConst(branch) && branchTypeMatches(branch, value)) {
218+
// Locked in: this is the only branch allowed to match. Return its
219+
// result verbatim — no fallthrough to other branches (incl. generic).
220+
const result = check(branch, value, path);
221+
return result.ok ? ok() : result;
222+
}
223+
}
224+
225+
// No strict branch claimed this `type`. Try every remaining branch
226+
// (non-strict-const, i.e. the generic variant). Pick the deepest-path
227+
// failure as the best diagnostic if none match.
192228
let bestFail: ValidationResult | null = null;
193-
let bestIntended = false;
194-
for (const branch of schema.oneOf) {
195-
const result = check(branch as SchemaNode, value, path);
229+
for (const rawBranch of branches) {
230+
const branch = deref(rawBranch);
231+
if (branchIsStrictConst(branch)) continue; // already eliminated above
232+
const result = check(branch, value, path);
196233
if (result.ok) return ok();
197-
const intended = branchTypeMatches(deref(branch as SchemaNode), value);
198-
const replace =
234+
if (
199235
bestFail === null ||
200-
(intended && !bestIntended) ||
201-
(intended === bestIntended &&
202-
!bestFail.ok &&
203-
!result.ok &&
204-
result.path.length > bestFail.path.length);
205-
if (replace) {
236+
(!bestFail.ok && !result.ok && result.path.length > bestFail.path.length)
237+
) {
206238
bestFail = result;
207-
bestIntended = intended;
208239
}
209240
}
210241
if (bestFail !== null) return bestFail;

0 commit comments

Comments
 (0)