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
+10 -8
View File
@@ -1,7 +1,7 @@
"use client";
import { ClipboardCheckIcon } from "lucide-react";
import type { ProbeDoneResult, ProbeDoneInput } from "@/src/core";
import { parseProbeDoneInput, type ProbeDoneResult } from "@/src/core";
/**
* The probe conclusion card (docs/probe-teach-split.md §2): renders the
@@ -22,16 +22,18 @@ import type { ProbeDoneResult, ProbeDoneInput } from "@/src/core";
* the UI wave; this card just guarantees the data is never invisible.
*/
export function ProbeDoneCard({ part }: { part: { input?: unknown; output?: unknown; state?: unknown } }) {
const input = part.input as ProbeDoneInput | undefined;
const output = part.output as ProbeDoneResult | undefined;
const state = typeof part.state === "string" ? part.state : "";
const assessment =
output?.ok
? output.assessment
: state === "output-error"
? null // the rejected call — its input is unvalidated garbage
: (input?.assessment ?? null);
// Parse, don't cast (the seam rule, src/core/tool-parts.ts): the model
// authored this input — it is consumer-trustworthy only if the SAME
// schema the tool declared says so. The parse subsumes the old state
// reasoning and shape guards: an errored call's stringified assessment,
// a mid-stream partial — anything that doesn't parse falls to the
// fallback branch. Output stays a cast: OUR execute authored it.
const input = parseProbeDoneInput(part.input);
const assessment = output?.ok ? output.assessment : input?.assessment;
const strands = assessment && Array.isArray(assessment.strands) ? assessment.strands : null;
return (
+18 -2
View File
@@ -13,6 +13,8 @@ import {
resolveCorrect,
seededRandom,
shuffleOptions,
parseAskInput,
parseQuizInput,
toggleOption,
toggleOther,
type AnswerRequest,
@@ -76,12 +78,26 @@ export function QuestionDock({
onSubmit,
phase = "probe",
}: QuestionDockProps) {
// Parse, don't cast (src/core/tool-parts.ts): the model authored this
// input, and only the schema the tool declared vouches for its shape.
// The dock mounts on input-available parts (already validated at the
// bridge), so this is pure defense in depth — but it is STRUCTURAL now:
// anything that slips through renders the quiet fallback, never a
// crash on `.map` over model-authored bytes.
const parsed = toolName === "quiz" ? parseQuizInput(input) : parseAskInput(input);
if (!parsed) {
return (
<div data-testid="malformed-question" className="rounded-xl border border-dashed bg-card/60 p-3 text-sm text-muted-foreground">
This question arrived malformed — the transcript keeps the retry.
</div>
);
}
return (
<>
{!expanded && (
<QuestionDockBar
toolName={toolName}
question={input.question}
question={parsed.question}
onExpand={onExpand}
variant={reveal ? "reveal" : "collapsed"}
/>
@@ -90,7 +106,7 @@ export function QuestionDock({
<QuestionDockForm
toolName={toolName}
toolCallId={toolCallId}
input={input}
input={parsed}
phase={phase}
onSubmit={onSubmit}
/>