fix(arch): parse, don't cast — the UI parse seam for model-authored input

The architectural answer to the validation-crash question: validation IS
first at the model-facing boundary (pi validates arguments before
execute; failures return to the model same-turn — all three live
incidents retried successfully). But the same bytes also exist UNVALIDATED
by design in two other places: the live wire streams raw tool-input
deltas DURING generation, and the transcript keeps the raw input of
REJECTED calls (honesty + the replay invariant). The consumers were
supposed to trust part STATES — but that trust was enforced by
TypeScript casts ('part.input as X'), which are compile-time lies with
no runtime check behind them. That is the hole both crashes came
through, and it should not have been possible.

The structural rule now: model-authored INPUT is consumed only through
parsers running the SAME zod schema the model faced — boundary-valid ⇔
consumer-parseable, mechanically. Server/learner-authored OUTPUT stays
trusted (our code authors it).

- src/core/tool-parts.ts (new): parseQuizInput (display parse over the
  shared quizInputBase — strips the teacher-only nodeId, accepts both
  prober and teacher boundary shapes), parseAskInput, parseProbeDoneInput.
- contracts/probe.ts: probeDoneInputSchema is now core-owned zod — the
  probe_done tool declares it (single source of truth), the seam runs
  the literal same schema.
- question-dock.tsx: the dock parses its input at entry; unparsable
  renders the quiet malformed-question fallback, never a crash.
- probe-done-card.tsx: the input preview goes through the parse; the
  old state reasoning and shape guards collapse into it.

Pinned in tests/tool-parts.test.ts: both exact live crash shapes
(stringified quiz options, stringified assessment), boundary-parity
cases for all three schemas, nodeId stripping, mid-stream partials;
render-cards pins the dock fallback markup.

Gates: tsc, lint, build, 328/328.
This commit is contained in:
amosblomqvist
2026-09-12 04:51:39 +03:00
parent e821c3277d
commit 4ba594bab5
8 changed files with 274 additions and 36 deletions
+4 -26
View File
@@ -1,6 +1,5 @@
import { z } from "zod";
import { lhTool, type LhTool } from "../lh-tool.ts";
import type { ProbeDoneInput, ProbeDoneResult } from "../../core/contracts.ts";
import { probeDoneInputSchema, type ProbeDoneInput, type ProbeDoneResult } from "../../core/contracts.ts";
import type { UIMessage } from "ai";
import { findPendingToolCall } from "../../core/pending.ts";
import type { ChatStore } from "../../core/ports.ts";
@@ -56,30 +55,9 @@ export function probeDoneTool(ctx: ProbeDoneContext): Record<string, LhTool<any,
"(floor, ceiling, misconceptions, what to build from). It streams to the learner live and becomes " +
"the handoff artifact the teacher plans from. Refused while a question is still open, and after " +
"the probe is already concluded. After it succeeds, say your closing sentence and end your turn.",
schema: z.object({
assessment: z
.object({
strands: z
.array(
z.object({
strand: z.string().describe("The dependency strand (e.g. 'the derivative as a limit') — a name the plan can hang a node on."),
assessment: z
.string()
.describe(
"Where his edge sits on this strand: the floor (what he reliably gets right), the ceiling (where it runs out), " +
"misconceptions found, and what the lesson should build from.",
),
}),
)
.min(1)
.describe("Every strand the planned lesson will depend on, each with your read of his edge on it."),
summary: z
.string()
.optional()
.describe("Optional overall synthesis: his apparent goal and where the teaching should enter."),
})
.describe("The learner assessment — the probe's distilled output."),
}) satisfies z.ZodType<ProbeDoneInput>,
// The core-owned schema (contracts/probe.ts) — the SAME schema the UI
// parse seam (tool-parts.ts) runs, so boundary-valid ⇔ consumer-parseable.
schema: probeDoneInputSchema,
execute: async (input): Promise<ProbeDoneResult> => {
const record = ctx.chatStore.getChat(ctx.chatId);
if (!record) return { ok: false, error: `Chat not found: ${ctx.chatId}` };
+38
View File
@@ -15,6 +15,8 @@
* the learner's edge sits.
*/
import { z } from "zod";
/** One dependency strand the planned lesson will rest on. */
export interface ProbeStrand {
/** Name/description of the strand (e.g. "the derivative as a limit"). */
@@ -36,6 +38,42 @@ export interface ProbeDoneInput {
assessment: ProbeAssessment;
}
// The zod schema of that input — SINGLE SOURCE OF TRUTH, lived by the tool
// (src/agent/tools/probe.ts) and by the UI parse seam (src/core/tool-parts.ts):
// the boundary validation and the consumer parse run literally the same
// schema, so "schema-valid at the boundary" ⇔ "parseable by a consumer" is
// structural, not a convention. (Live crash 2026-09-12: the model sent
// `assessment` as a STRING; the card cast it and died on `.strands.map`.)
export const probeDoneInputSchema = z
.object({
assessment: z
.object({
strands: z
.array(
z.object({
strand: z
.string()
.describe(
"The dependency strand (e.g. 'the derivative as a limit') — a name the plan can hang a node on.",
),
assessment: z
.string()
.describe(
"Where his edge sits on this strand: the floor (what he reliably gets right), the ceiling (where it runs out), " +
"misconceptions found, and what the lesson should build from.",
),
}),
)
.min(1)
.describe("Every strand the planned lesson will depend on, each with your read of his edge on it."),
summary: z
.string()
.optional()
.describe("Optional overall synthesis: his apparent goal and where the teaching should enter."),
})
.describe("The learner assessment — the probe's distilled output."),
}) satisfies z.ZodType<ProbeDoneInput>;
/** probe_done tool output. */
export interface ProbeDoneResult {
ok: boolean;
+1
View File
@@ -24,4 +24,5 @@ export * from "./phases.ts";
export * from "./pending.ts";
export * from "./math-delims.ts";
export * from "./ui-message.ts";
export * from "./tool-parts.ts";
export * from "./prices.ts";
+68
View File
@@ -0,0 +1,68 @@
/**
* The UI parse seam — parse, don't cast (the rule born of the 2026-09-12
* live crashes).
*
* WHY THIS EXISTS. Validation IS first at the model-facing boundary — pi
* validates arguments before execute, and our zod re-run adds the
* refinements; a failure returns to the model as an error tool result,
* same turn. But the same bytes have two other consumers, and there they
* exist UNVALIDATED BY DESIGN:
*
* - the live wire streams raw tool-input deltas while the model is still
* typing the call (validation cannot run before the call is complete);
* - the transcript keeps the raw input of REJECTED calls too (transcript
* honesty + the replay invariant: every toolCall followed by a result).
*
* So invalid input is always observable, and part STATES carry the verdict
* (input-streaming = partial, input-available = validated, output-error =
* garbage). The flaw that made the crashes possible: consumers trusted
* those shapes via TypeScript CASTS — `part.input as X` is a compile-time
* lie the checker believes, with no runtime check behind it.
*
* THE RULE (mechanical, not vigilance): model-authored INPUT is consumed
* only through these parsers, each running the SAME zod schema the model
* faced — boundary-valid ⇔ consumer-parseable, structurally. Server- and
* learner-authored OUTPUT stays trusted (our own code authors it: the
* execute acks, the learner's buildAnswer). A parse failure renders the
* quiet fallback, never a crash.
*/
import { z } from "zod";
import { quizInputBase, askInputSchema, type AskToolInput, type QuizToolInput } from "./question-core.ts";
import { probeDoneInputSchema, type ProbeDoneInput } from "./contracts/probe.ts";
/**
* Quiz parse for DISPLAY: the shared authoring base, pre-refinement.
* Both boundary schemas (the prober's probeQuizInputSchema over the bare
* base, the teacher's quizInputSchema over base + optional nodeId) refine
* THE SAME quizInputBase, so anything that passed either boundary parses
* here: refinements don't weaken on re-run, and the teacher-only `nodeId`
* is an unknown key the base strips. This is deliberately the LENIENT
* common core — the strict per-agent refinements are the boundary's job.
*/
export function parseQuizInput(input: unknown): QuizToolInput | undefined {
const parsed = quizInputBase.safeParse(input);
return parsed.success ? (parsed.data as QuizToolInput) : undefined;
}
/**
* Ask parse for DISPLAY: the full ask schema (options optional — free-text
* mode is the options-less shape). The only refinement is option sanity
* (distinct values), which the boundary already enforced.
*/
export function parseAskInput(input: unknown): AskToolInput | undefined {
const parsed = askInputSchema.safeParse(input);
return parsed.success ? parsed.data : undefined;
}
/** probe_done parse — the core-owned schema the tool itself declares. */
export function parseProbeDoneInput(input: unknown): ProbeDoneInput | undefined {
const parsed = probeDoneInputSchema.safeParse(input);
return parsed.success ? parsed.data : undefined;
}
/** Parse helper for schemas this seam doesn't own (kept for future cards). */
export function parseWith<T>(schema: z.ZodType<T>, input: unknown): T | undefined {
const parsed = schema.safeParse(input);
return parsed.success ? parsed.data : undefined;
}