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:
- 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).
- Per-card error boundary —
renderSummaryPayloadnow 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.