fix(dashboard): carry the slots offender note on every read path - #9076
Conversation
Design Review (Fable 5, fork) — ✅ PASSDesign-level review of Both load-bearing claims verified against the base tree: the connect block genuinely sits under a bare Design-Verdict: PASS Sanctioned follow-up that completes the #8888 diagnostic seam correctly; each read path now fails with a named offender instead of a bare TypeError. Suggestions
[DESIGN-REVIEWED] af49997 |
GPT 5.6 Review (fork) — ✅ no blocking findingsReviewed Review detailsNo findings. |
Opus 4.8 Review (fork) — ✅ no blocking findingsReviewed Review detailsI have enough to decide. Let me verify the assert-related claim by checking how the function is actually reached in production — whether any caller could pass a non-list. The diff shows Candidate 1 falsification:
The candidate's own confidence is "low," and the failing-test-under- Step 2: The remaining behavioral changes are all serialization-equivalent to what they replace — No findings. [OPUS-REVIEWED] af49997 |
First Principles Review (Fable 5, fork) — ✅ PASSPremise-level review of Verification complete. The base confirms every load-bearing claim: the helper exists with exactly one consumer at base ( First-Principles-Verdict: PASS A sanctioned follow-up that routes three bare-failing dumps of one projection through the one existing diagnostic, deleting dead branches along the way. What this change shipsIntent: make every slots read path fail with the named-offender diagnostic #8888 gave only the broadcast — a FIX (completing a deferred fix).
The one dump of this projection left unwrapped, [FIRST-PRINCIPLES-REVIEWED] af49997 |
…odotdev#8745 class) The slots-broadcast diagnostic from kirodotdev#8888 named the slot and field that break JSON serialization, but only on the coalesced broadcast. The three sibling read paths serialize the same projection and still failed with the bare 'Object of type X is not JSON serializable': - the dashboard-user WS frame (_slots_ws_frame, covering both its send sites) — the enriched projection, so a value only enrichment adds raised here and nowhere else; - the WS connect snapshot — which sits under 'except Exception: pass', so a broken snapshot meant an empty sidebar with zero evidence; it now also logs a WARNING (the note alone would vanish with the swallowed exception), with the exception flow itself unchanged; - GET /api/chat/slots — web.json_response ran the dump internally; the handler now dumps explicitly (json_response is Response(text=dumps(..)) so the healthy response is unchanged) and annotates the failure. The note helper gains a path label naming which read path raised, and its two shape-check branches are removed: every caller passes serialize_slots() output (list-of-dicts by construction), and any surprise still degrades to the generic note through the defensive except instead of raising. The connect snapshot now sends the pre-dumped string (the established shape on every other slots send path), so the WS test doubles gain a send_str that parses back to the dict frames their assertions read; one double's folders_generation had returned a raw MagicMock, which a fake send_json never serialized and the real dump now rightly rejects.
886e962 to
af49997
Compare
bolichen97
left a comment
There was a problem hiding this comment.
Tech Lead review — APPROVE.
Verified the completeness claim against the head tree (af49997) rather than the PR body. Every dump of the slots projection is now annotated:
| site | path label |
|---|---|
_do_slots_broadcast bare list (state.py:7846) |
slots-broadcast (default, byte-identical to #8888) |
generic dashboard-user frame via _slots_ws_frame (state.py:8049) |
ws-frame |
owner frame via _slots_ws_frame (state.py:7926) |
ws-frame |
GET /api/chat/slots (chat_handlers.py:1118) |
GET /api/chat/slots |
WS connect snapshot (ws.py:699) |
ws-connect-snapshot + WARNING |
Independently grepped for sibling read paths beyond the four the PR names. The only other json.dumps({"type": "slots", ...}) is the app-token frame in websocket_hub.py:210, and it is genuinely not an uncovered path: _send_ws_all is reached only after _slots_ws_frame (or the broadcast dump) has already dumped the same projection, filtered is a subset of it, extras is slots_envelope_extras → {"yolo": bool}, and any residual failure there already logs a WARNING with exc_info in _send_ws_all. The SSE path consumes the pre-dumped note["slots"] string from the annotated broadcast dump, so it inherits the note. broadcast_ws_subagent_subscribers never carries msg_type == "slots".
The removed shape-check branches degrade correctly rather than raising: under -O a non-list input reaches enumerate/.get and lands in the defensive except Exception, returning the generic note. The web.json_response → web.Response(text=dumps(...)) swap is behaviour-preserving on the healthy path, and the connect-snapshot send_json → send_str(pre-dumped) swap matches every other slots send site.
Scope is tight and coherent: 6 files, +365/-55, backend-only, one commit, no unrelated churn. All checks green (0 failures), all five AI lanes clear, readiness: passed.
Design Review's suggestion to extract a _dump_slots_with_note(payload, slots, path) so a fifth read path cannot regress is a real improvement and matches this PR's own pattern harvest — taking it as advisory follow-up, not a merge blocker, since the four sites here are correct as written.
Problem / Motivation
#8888 added
_slots_serialization_note, so when the open-slots projection picks up a valuejson.dumpscannot serialize, the coalesced slots broadcast fails with a note naming the exact slot and field instead of a bareObject of type X is not JSON serializable. But the broadcast is only one of four places that serialize that projection. The three sibling read paths still fail bare:_slots_ws_frame, covering both of its send sites) — this one serializes the enriched projection, so a poisoned value that only enrichment adds raises here and nowhere else;except Exception: pass, so a broken snapshot today means an empty sidebar with zero evidence anywhere;GET /api/chat/slots—web.json_responseruns the dump internally, out of reach of the diagnostic.Why it matters
The offender note exists precisely because these failures are data-dependent and intermittent: whichever path serializes the poisoned projection first is the one that fails, and three of the four paths still produce the undebuggable bare TypeError (or, on the connect snapshot, silence). The FP review on #8888 called this out and sanctioned the follow-up; this PR completes the seam so every read path fails with the same named-offender diagnostic.
What changed (motivation → approach → change)
All three remaining paths now dump explicitly and annotate failures through the shared helper:
_slots_serialization_notegains apathkeyword (default keeps the wired broadcast site byte-identical) so the note names which read path raised, and its two unreachable shape-check branches are removed — every caller passesserialize_slots()output, which is list-of-dicts by construction, and any surprise still degrades to the generic note through the defensiveexceptinstead of raising._slots_ws_framewraps its dump, annotates withpath="ws-frame", and re-raises — one seam covering both of its send sites (fan-out and owner projections).GET /api/chat/slotsdumps explicitly and returnsweb.Response(text=..., content_type="application/json")—json_responseisResponse(text=dumps(...)), so the healthy response is unchanged; the failure path now carries the note.path="ws-connect-snapshot", and also logs a WARNING before re-raising — a deliberate addition, disclosed here: the outerexcept Exception: passswallows the annotated exception, so without the log line the note would vanish with it. One log line on a today-silent failure is the minimal honest diagnosability; the exception flow itself (swallow-and-continue) is unchanged. The snapshot also now sends the pre-dumped string viasend_str— the established shape on every other slots send path (send_jsonissend_str(dumps(...))).Test doubles: the WS fakes gain a
send_strthat parses back to the dict frames existing assertions read; one double'sfolders_generationhad returned a rawMagicMock, which a fakesend_jsonnever serialized and the real dump now rightly rejects.Tests
Contract-generated, reusing the
_poison_slots_projectionhelper and REST app fixtures from the #8888 suite (test/test_open_slots_persistence.py, +219 lines):GET /api/chat/slots(fails-before: bare TypeError from insidejson_response);_slots_ws_framedirect: poisoned enriched projection → TypeError note with[ws-frame](fails-before: bare);helper(42)→ generic note, no raise;json_response, WS frame dict equality).Fails-before reproduced at the pristine base (this suite copied onto
3a6478967): 5 failed / 69 passed — the three named-offender assertions, the path-label test, and the connect-snapshot log assertion all fail exactly as designed. Full 7-section gate run (targeted 345 passed / seam-neighbor 295 passed / mypy / flake8 / isort / black-baseline / docs-lint) green at the exact commit.Manual verification
N/A — the seam is deterministic (same poisoned projection in, same annotated failure out) and the benign controls pin byte-identical healthy behavior on all four paths.
Screenshots / video
Why no screenshot: backend-only diff (dashboard serialization diagnostics; no rendered UI change). The only user-visible delta is a clearer error annotation in logs/API error payloads, covered by the attack tests above.
Related Issues
Follow-up to #8888 (same seam, deferral named in that PR's review round); completes the offender-note coverage started there. Diagnostic class as #8745.
Pattern harvest
Pattern: a diagnostic added at one serialization site of a shared projection leaves sibling read paths (REST handler, WS snapshot, WS frame) failing bare — the fix is to route every dump of that projection through the one annotating helper, with a path label so the note also says where.
Rule candidate: review-prompt — "when a serialization diagnostic wraps one call site, list every other place the same object is serialized (grep the projection builder's callers); require either shared-helper routing or an explicit rationale per uncovered path, and flag any covered path that sits under a swallowing except without a log line."
Checklist
feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)Contribution License Agreement
Per the template placeholder (CLA text pending): offered under the same terms as my prior merged contributions to this repository (#8835, #8888).