유대선
프로젝트로
·트러블슈팅·2

One malformed summary card took down the whole app: the dispatcher that defended four kinds and forgot the fifth

A concept-kind dk-summary card missing its `tradeoffs` field crashed the entire app with 'undefined is not an object (p.pros)'. The shared card renderer defensively shaped four card kinds but passed the fifth (concept) raw — and the same renderer feeds three surfaces, so one bad card from a BYO LLM nuked all of them. Fixed with a null-safe ConceptCard plus a per-card error boundary.

The app threw up a full-screen error boundary: undefined is not an object (evaluating 'p.pros'), top of stack in summaryCard-*.js. Not dk-verify — the summary card renderer, the thing that draws the little recap/plan/concept cards each turn.

The asymmetry that was the bug

renderSummaryPayload dispatches a <dk-summary> payload to one of five card kinds. Four of them (recap, plan, note, question) carefully default every field: Array.isArray(d.steps) ? d.steps : [], etc. The fifth — concept — was passed straight through: <ConceptCard data={d as any} />. And ConceptCard destructured const { tradeoffs: tr } = data and called tr.pros.map(...) with no guard. A concept card missing tradeoffs (a BYO LLM emits partial/truncated JSON all the time) → undefined.pros → crash. Because this one renderer is shared by SessionFlow, the Eval panel, and SummaryModal, a single malformed card in a transcript took down whichever surface rendered it.

The fix: defend the data AND isolate the card

Two layers, because a shared renderer of semi-trusted data deserves both:

  1. Null-safe ConceptCard — comparison, tradeoffs, analogy, and steps all coerce to safe defaults; a section with no data is simply omitted. This is the real fix (any caller is now safe, even in SSR).
  2. Per-card error boundaryrenderSummaryPayload now wraps each card so that if any card (today or a future kind) throws, it degrades to a one-line "couldn't render this card" notice and the rest of the flow still renders. One bad card can never take the surface down again.

Five regression tests pin it: malformed and empty concept payloads render without throwing, and a fully-formed card still shows its pros/cons.

Lesson

A dispatcher that defends four branches and passes the fifth raw is a loaded gun. When you render semi-trusted data through a shared component, defend every field and isolate every item — the blast radius of one partial record is otherwise the entire screen.