fix(dashboard): carry a chat_message row's identity through the SSE relay - #8199
Conversation
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsVerification confirms the change is sound: No findings. [OPUS-REVIEWED] 7989963 Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
Design Review (Fable 5) — ✅ PASSDesign-level review of All checks confirm the design claims: the middleware sets Design-Verdict: PASS One shared serialiser with a per-transport authorization decision is the correct root-cause shape; the security asymmetry is real and correctly preserved. Suggestions
[DESIGN-REVIEWED] 7989963 |
First Principles Review (Fable 5) — ✅ PASSPremise-level review of All claims verified. Writing the review. First-Principles-Verdict: PASS A filed contract defect fixed at its cause — one serialiser replaces two hand-built frames — with the security asymmetry and every rider declared and counted. What this change shipsIntent: make
Every count in the description held up: Subtractions
[FIRST-PRINCIPLES-REVIEWED] 7989963 |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsFINDING -- src/kiro_crew/dashboard/state.py:1942 -- docstring names nonexistent [GPT-REVIEWED] 7989963 False positive or not applicable? A repository writer can comment: |
|
Adopted the Design Review suggestion in The reviewer's point was that the frame is now built twice, held identical only by comments, spec prose and one-sided tests — "exactly how #8045 survived #7981". That is the same argument my own Pattern harvest section makes, so shipping the duplication while claiming the class is real would have been inconsistent. Both takes were available (extract a helper, or add a parity test); I did both, because they answer different halves.
One consequence worth naming: this does NOT pre-empt #8044. That issue is about Scope check — The GPT 5.6 lane on the previous head reported "review incomplete" — its first run was cancelled, not failed, and it has re-queued on this head; I am not requesting an override for it. |
|
Both lanes on GPT 5.6 — BLOCKING, accepted as correct. Not requesting an override. The finding is right and it invalidates the premise my previous commit rested on. I had argued "parity" between the two doors; they are not symmetric. The WS door filters per client downstream of the serialiser ( Confirming it is not novel, which is the part that convinced me fastest: I took the suggested shape (withhold from app tokens) rather than inventing an SSE scope gate, since giving that transport per-slot filtering is an authorization-architecture change a maintainer should own. First Principles — CONCERNS, both items accepted. The third test did not guard its docstring. Correct, and it was my error: its "WS payload" called Stale description. Also correct — the body said two tests and claimed a The four divergent sibling arms ( One thing neither lane raised, which I think matters more than my delta. The SSE |
…elay `DashboardState._broadcast()` feeds ONE producer note to TWO doors. The WebSocket arm carried `cls`/`meta`; the SSE arm in `handlers/updates.py` rebuilt a four-field subset (`slot`/`role`/`content`/`ts`) and dropped both. `meta.mid` is the per-row delivery identity `_ChatSlot.append` stamps, and it is what a client dedups on (`isRedeliveredMessage` in `chatSlice.ts` -- "a frame with NO `mid` is never deduped"). So the SSE door published frames no consumer can recognise as a redelivery. It is the same mid-less door #7981 closed; that PR's changed-file set never included this serialiser, which is why #8045 was filed as the remaining one. Scope, stated honestly: `/api/stream` has no in-repo frontend consumer today (the SPA reads `/api/ws`), so this is contract integrity rather than a live rendering symptom -- it does not fix visible duplicate bubbles. The endpoint is still served, documented, and proxy-allowlisted (`_PROXY_ALLOWED_PREFIXES` forwards a peer's `api/stream` for the remote-crew view). ONE serialiser, and an explicit authorization decision per transport. `chat_message_frame(note, *, include_metadata)` lives in state.py's shared-helpers section beside `parse_cls_meta`. Both doors call it, so a field added to the frame cannot reach one transport and miss the other -- the duplication that let #8045 survive #7981. `include_metadata` is REQUIRED and names a property of the transport, not a preference. The WS arm passes True because it filters per socket downstream (`_send_ws_all` -> `_ws_client_allowed`, deny-by-default event scope -> `_serialize_for_client`). `api_stream` passes `request["is_dashboard_user"]` only: `_broadcast()` fans the raw note to every SSE queue with NO per-app filtering, and `meta` carries `tool_input`, a live `oauth_url` and `approval_id`, so an unconditional pass would expose it to any app token granted that route whatever its `slots:*` scope. state.py already documents this exact class on this exact endpoint (GPT #6789, public-repo status leaked onto `/api/stream`); the established remedy is to keep enrichment on the door that filters. The auth read is the middleware's POSITIVE signal, taken once per connection and defaulting to False so a middleware-bypassing path withholds rather than publishes. An app token's frame is byte-for-byte the pre-existing four-field projection, so this withholds new metadata rather than newly restricting anyone. `cls`/`meta` stay conditional in both directions when included: absent metadata stays absent rather than arriving as `null`/`{}` keys a consumer must special-case. Four tests in test/test_dashboard_updates_coverage.py: - `test_the_sse_chat_message_frame_carries_the_rows_identity` -- the defect repro; fails `KeyError: 'meta'` against the unfixed serialiser. - `test_a_chat_message_without_meta_gains_no_empty_keys` -- no invented empty keys. Passes either way by design; pins the conditional, not the bug. - `test_an_app_token_stream_never_receives_row_metadata` -- the scope gate; verified by removing the gate, which puts `tool_input: "secret"` on the wire. - `test_neither_chat_message_door_builds_the_frame_by_hand` -- source-level guard that both doors delegate to the shared serialiser. Asserted on source because a payload comparison that calls the helper directly still passes after a door is re-inlined. Scoped to each door's own `chat_message` branch: the helper is DEFINED in state.py, so a whole-file scan reads its body as a hand-built frame and its `def` line satisfies a naive call check. Verified silent on baseline and firing on both re-inlining directions. `_request_with_queue` wires `is_dashboard_user` to a real mapping, because a bare MagicMock returns a truthy `.get()` and the app-token assertion would otherwise pass on mock truthiness. Spec: docs/system-specs/modules/learn-cron-dashboard.md's `_broadcast()` clause now states the one-serialiser rule AND the transport asymmetry, per AGENTS.md's same-commit rule for the dashboard-handlers row. Known-divergent siblings left to #8044: `refresh` (WS sends `kinds` as a list, SSE the raw comma-string) and `update_progress`/`artifact_update`/ `session_summary` (typed WS envelopes vs the raw-note SSE fallback). Verified: 78 in test_dashboard_updates_coverage.py; 106 across test_midless_broadcast_dedup, test_dashboard_state_ws, test_dashboard_state_close_characterization, test_slots_broadcast_coalesce and test_outbox_notify_broadcast (state.py is on the broadcast path). mypy clean (1280 files), flake8 + isort clean, black ratchet passes with both source files black-clean and unbaselined. Fixes #8045
f0f0387 to
7989963
Compare
bolichen97
left a comment
There was a problem hiding this comment.
Approving on the strength of a full readiness audit of every open PR against main, not a
line-by-line reading of this diff — recording that plainly so the next reader knows what this
stamp does and does not cover.
Verified against this exact head SHA:
readiness: passedpresent, andPR Readiness— the one required status context onmain
(rulesetprotected-branches) — issuccesson this head.- No check run on this head is
failure,cancelled,timed_outor still in flight. Skipped
jobs are path-filtered conditionals, none of them required. mergeable: true, and the head is not far enough behindmainfor its green CI to describe a
base that no longer exists.- No surviving reviewer
CHANGES_REQUESTED: any such review is on an older commit and therefore
already dismissed bydismiss_stale_reviews_on_push. - Every issue comment, inline review comment and review thread was read and classified. Nothing
left is an unresolved human change request — the remainder is bot review-lane output, resolved
or outdated threads, explicitly non-blocking suggestions, and author status notes.
Auto-merge (squash) is armed, so this lands once every other ruleset requirement is met.
Fixes #8045.
The defect
DashboardState._broadcast()feeds one producer note to two doors. TheWebSocket arm carried
cls/meta; the SSE arm inhandlers/updates.pyrebuilt a four-field subset (
slot/role/content/ts) and dropped both.meta.midis the per-row delivery identity_ChatSlot.appendstamps, and it iswhat a client dedups on (
isRedeliveredMessageinchatSlice.ts— "a frame withNO
midis never deduped"). So the SSE door published frames no consumer canrecognise as a redelivery. Same mid-less door #7981 closed; its changed-file set
never included this serialiser, which is why #8045 was filed as the remaining one.
Scope, stated honestly
/api/streamhas no in-repo frontend consumer today — the SPA reads/api/ws. This is contract integrity, not a live rendering symptom; it doesnot fix visible duplicate bubbles, because that is not reachable here. The
endpoint is still served, documented, and proxy-allowlisted
(
_PROXY_ALLOWED_PREFIXESforwards a peer'sapi/streamfor the remote-crewview).
The change
One serialiser, and an explicit authorization decision per transport.
chat_message_frame(note, *, include_metadata)lives instate.py'sshared-helpers section beside
parse_cls_meta. Both doors call it, so a fieldadded to the frame cannot reach one transport and miss the other.
include_metadatais a required keyword and names a property of thetransport, not a preference:
include_metadata_broadcast_note)_send_ws_all→_ws_client_allowed(deny-by-default event scope) →_serialize_for_clientTrue, gate decides per socketapi_stream)_broadcast()fans the raw note to every queuerequest["is_dashboard_user"]onlyThat asymmetry is the whole point, and getting it wrong is what GPT 5.6 caught on
b6276c57c: identical shape is not identical safety.metacarriestool_input, a liveoauth_url, andapproval_id, so an unconditional pass onthe unfiltered queue would expose it to any app token granted that route
regardless of its
slots:*scope.state.pyalready documents this exact classon this exact endpoint (GPT #6789, public-repo status leaked onto
/api/stream),and the established remedy is the one applied here: keep enrichment on the door
that filters.
The SSE auth read is the middleware's POSITIVE signal, taken once per connection
and defaulting to
False, so a path that bypasses the middleware withholdsrather than publishes. An app token's frame is byte-for-byte the pre-existing
four-field projection — this withholds new metadata, it does not newly restrict
anyone.
cls/metaremain conditional in both directions when included: absent metadatastays absent rather than becoming
null/{}keys a consumer must special-case.Tests
Four in
test/test_dashboard_updates_coverage.py:test_the_sse_chat_message_frame_carries_the_rows_identity— the defectrepro. Fails
KeyError: 'meta'against the unfixed serialiser.test_a_chat_message_without_meta_gains_no_empty_keys— no invented emptykeys. Passes either way by design; pins the conditional, not the bug.
test_an_app_token_stream_never_receives_row_metadata— the security gate.Verified by removing the gate: it fails with
tool_input: "secret"present.test_neither_chat_message_door_builds_the_frame_by_hand— source-levelguard that both doors delegate to the shared serialiser.
On (4): it replaces an earlier byte-parity test that First Principles correctly
showed did not guard its own docstring — its "WS payload" called
chat_message_framedirectly, so re-inlining the WS arm was invisible to it.Deleting it was the reviewer's suggestion, but that would drop what Design Review
asked for, so it became a source-level guard instead (the repo's existing pattern
for this, cf.
test_no_consumer_hardcodes_its_own_warn_threshold). It is scopedto each door's own
chat_messagebranch, because a whole-file scan cannot workhere: the helper is defined in
state.py, so its own body reads as ahand-built frame and its
defline satisfies a naive "calls the helper" check. Iverified it stays silent on baseline and fires on both re-inlining directions.
Harness fix:
_request_with_queuewiresis_dashboard_userto a real mapping —a bare
MagicMockreturns a truthy.get(), so test 3 would otherwise havepassed on mock truthiness.
Known-divergent siblings (out of scope)
First Principles counted four arms still hand-built and divergent between the two
doors:
refresh(WS sendskindsas a list, SSE the raw comma-string) andupdate_progress/artifact_update/session_summary(typed WS envelopes vsthe raw-note SSE
notificationfallback). Real, and tracked by #8044 rather thanwidened into here.
Pattern harvest
Rule candidate: review-prompt
Pattern: a second delivery door rebuilds a shared producer note by hand, and enrichment is added to a payload that feeds both a filtered and an unfiltered transport
Two distinct generalizations, and this PR hit both — the second one the hard way.
Duplicated frame construction. One producer, N transports each re-deriving
the payload, so a new field reaches only the doors someone remembered to edit.
Already filed as its own instances: #7981, #8044, #8005.
Enriching a payload that feeds an unfiltered transport. The sharper and more
dangerous one.
_broadcast()fans one note to a door that filters per client anda door that does not, so any field added centrally silently acquires the weaker
of the two authorization models. GPT #6789 was this exact bug on this exact
endpoint; my own first attempt reproduced it while explicitly arguing for
"parity", which is the tell — parity of shape between transports of unequal
privilege is not a safety property. The reviewable question: "does this payload
feed a transport with no per-client gate, and does the field I am adding carry
anything scope-sensitive?"
Why review-prompt over semgrep: both tells are semantic. A dict literal
enumerating a subset of a note is syntactically indistinguishable from a
deliberately narrow envelope, and "this queue has no downstream filter" is a
property of the fan-out, not of the line being edited.
Verification
pytest test/test_dashboard_updates_coverage.py— 78 passedpytestbroadcast/state modules (state.pyis on that path) — 106 passedacross
test_midless_broadcast_dedup,test_dashboard_state_ws,test_dashboard_state_close_characterization,test_slots_broadcast_coalesce,test_outbox_notify_broadcastmypy src/kiro_crew/— clean, 1280 filesflake8+isort --check-only— cleanscripts/check_black_formatting.py— passes; both source files are black-cleanand unbaselined, and no added line of mine is unformatted
Tool versions match the CI pins (pytest 9.0.3, black 26.3.1, flake8 7.1.0,
mypy 1.14.1, isort 6.0.0).