From 7717fcccdde956a83c39ca0d71d5ba32330b1321 Mon Sep 17 00:00:00 2001 From: Logan Cusano Date: Mon, 14 Sep 2026 00:09:26 -0400 Subject: [PATCH 1/2] correlator/admin: capture per-scene incident_type + severity (#139) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _call_is_substanceless's "type" veto reads ctx["incident_type"] at decision time, but that value was never persisted per-scene — only the last-scene-wins flat field, which #138's window-4 dump analysis couldn't tell apart from cross-scene contamination without re-guessing from a live dump. Adds incident_type/severity to _apply_and_log's per-scene write and to admin.py's _scene_summary allowlist (the debug-dump reader has its own field allowlist, separate from the write side — silently would not have surfaced otherwise). Sandboxed pytest: 380 -> 381. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Tbknwttzou4s46PAykmtix --- .../app/internal/incident_correlator.py | 14 ++++-- drb-c2-core/app/routers/admin.py | 5 ++ drb-c2-core/tests/test_per_scene_call_doc.py | 50 +++++++++++++++++++ 3 files changed, 66 insertions(+), 3 deletions(-) diff --git a/drb-c2-core/app/internal/incident_correlator.py b/drb-c2-core/app/internal/incident_correlator.py index 8846314..d2698b2 100644 --- a/drb-c2-core/app/internal/incident_correlator.py +++ b/drb-c2-core/app/internal/incident_correlator.py @@ -1458,9 +1458,17 @@ async def _apply_and_log(decision: dict, ctx: dict) -> Optional[str]: updates = dict(corr_debug) updates["scenes"] = { str(scene_index): { - "transcript": ctx.get("scene_transcript"), - "incident_id": incident_id, - "corr_debug": corr_debug, + "transcript": ctx.get("scene_transcript"), + "incident_id": incident_id, + "corr_debug": corr_debug, + # server-26#139: this scene's OWN extracted incident_type/ + # severity, as read by _call_is_substanceless's ctx at + # decision time — not the call doc's flat top-level field, + # which is last-scene-wins (server-26#96) and was the reason + # #138's "type" veto couldn't be told apart from cross-scene + # contamination without re-guessing from a live dump. + "incident_type": ctx.get("incident_type"), + "severity": ctx.get("call_severity"), } } try: diff --git a/drb-c2-core/app/routers/admin.py b/drb-c2-core/app/routers/admin.py index eeb04c0..af7eac2 100644 --- a/drb-c2-core/app/routers/admin.py +++ b/drb-c2-core/app/routers/admin.py @@ -109,6 +109,11 @@ async def debug_correlation( "scene_index": scene_index, "transcript": scene.get("transcript"), "incident_id": scene.get("incident_id"), + # server-26#139: this scene's OWN incident_type/severity, as seen + # by _call_is_substanceless at decision time — not the call doc's + # flat top-level field, which is last-scene-wins (server-26#96). + "incident_type": scene.get("incident_type"), + "severity": scene.get("severity"), "corr_path": corr_debug.get("corr_path"), "corr_incident_idle_min": corr_debug.get("corr_incident_idle_min"), "corr_distance_km": corr_debug.get("corr_distance_km"), diff --git a/drb-c2-core/tests/test_per_scene_call_doc.py b/drb-c2-core/tests/test_per_scene_call_doc.py index 5fbabdb..b9b7ca1 100644 --- a/drb-c2-core/tests/test_per_scene_call_doc.py +++ b/drb-c2-core/tests/test_per_scene_call_doc.py @@ -123,10 +123,60 @@ async def test_single_scene_call_still_gets_a_scenes_map_equivalent_to_flat_fiel "transcript": "10-4", "incident_id": None, "corr_debug": {"corr_path": "fast/thin", "corr_consensus": "rules_only"}, + "incident_type": None, + "severity": None, } } +@pytest.mark.asyncio +async def test_scene_entry_captures_its_own_incident_type_not_a_sibling_scenes(): + """ + server-26#139: _call_is_substanceless's "type" veto reads ctx["incident_type"] + at decision time, but that value was never persisted per-scene — only the + last-scene-wins flat field, which #138's dump analysis couldn't + distinguish from cross-scene contamination. Each scene's own scenes. + entry must carry its own incident_type/severity, distinct from any other + scene on the same call. + """ + docs: dict[tuple, dict] = {} + + async def fake_doc_set(collection, doc_id, data, merge=True): + docs.setdefault((collection, doc_id), {}) + _merge(docs[(collection, doc_id)], data) + + decision0 = { + "action": "orphan", "matched_incident": None, "incident_type": None, + "corr_debug": {"corr_path": "new", "corr_consensus": "tiebreak", "corr_gate_veto": "type"}, + } + ctx0 = { + "call_id": "call-5", "scene_index": 0, "scene_transcript": "10-4, clear", + "incident_type": "traffic-stop", "call_severity": "routine", + } + + decision1 = { + "action": "orphan", "matched_incident": None, "incident_type": None, + "corr_debug": {"corr_path": "new", "corr_consensus": "agreed"}, + } + ctx1 = { + "call_id": "call-5", "scene_index": 1, "scene_transcript": "roll call", + "incident_type": None, "call_severity": "routine", + } + + with patch.object(incident_correlator, "fstore") as mock_fstore: + mock_fstore.doc_set = fake_doc_set + await incident_correlator._apply_and_log(decision0, ctx0) + await incident_correlator._apply_and_log(decision1, ctx1) + + scenes = docs[("calls", "call-5")]["scenes"] + assert scenes["0"]["incident_type"] == "traffic-stop" + assert scenes["0"]["severity"] == "routine" + assert scenes["1"]["incident_type"] is None + # _apply_and_log never writes incident_type/severity to the flat + # top-level fields at all (only corr_debug's own keys go flat) — the + # per-scene entry is the only place either value is ever persisted. + + @pytest.mark.asyncio async def test_empty_corr_debug_writes_nothing_same_as_before(): """Preserve the pre-#96 short-circuit: no corr_debug means no write at From f23026b9ab204414e63ec2eb537e58ae56e3582a Mon Sep 17 00:00:00 2001 From: Logan Cusano Date: Mon, 14 Sep 2026 00:14:32 -0400 Subject: [PATCH 2/2] correlator: address drb-correlation-review notes on #139 - Document call_severity's routine-coercion asymmetry with incident_type (extraction-said-routine vs extraction-said-nothing look identical). - Test docstring no longer overclaims the ctx-linkage it doesn't cover; points to the tests that do (test_consensus_gate.py, test_incident_identity.py). - scene1 now uses a distinct severity so the test actually exercises both fields symmetrically; the 'no flat top-level clobber' claim is now asserted, not just commented. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Tbknwttzou4s46PAykmtix --- .../app/internal/incident_correlator.py | 7 ++++++ drb-c2-core/tests/test_per_scene_call_doc.py | 22 ++++++++++++------- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/drb-c2-core/app/internal/incident_correlator.py b/drb-c2-core/app/internal/incident_correlator.py index d2698b2..795bd69 100644 --- a/drb-c2-core/app/internal/incident_correlator.py +++ b/drb-c2-core/app/internal/incident_correlator.py @@ -1467,6 +1467,13 @@ async def _apply_and_log(decision: dict, ctx: dict) -> Optional[str]: # which is last-scene-wins (server-26#96) and was the reason # #138's "type" veto couldn't be told apart from cross-scene # contamination without re-guessing from a live dump. + # NOTE: unlike incident_type, call_severity is already + # coerced to "routine" when extraction emitted nothing + # (ctx build: `severity or "routine"`) — a scene reading + # "routine" here doesn't distinguish "extraction said + # routine" from "extraction said nothing". Don't split a + # severity veto the way #138 splits the type veto without + # accounting for that. "incident_type": ctx.get("incident_type"), "severity": ctx.get("call_severity"), } diff --git a/drb-c2-core/tests/test_per_scene_call_doc.py b/drb-c2-core/tests/test_per_scene_call_doc.py index b9b7ca1..15a2d74 100644 --- a/drb-c2-core/tests/test_per_scene_call_doc.py +++ b/drb-c2-core/tests/test_per_scene_call_doc.py @@ -135,9 +135,12 @@ async def test_scene_entry_captures_its_own_incident_type_not_a_sibling_scenes() server-26#139: _call_is_substanceless's "type" veto reads ctx["incident_type"] at decision time, but that value was never persisted per-scene — only the last-scene-wins flat field, which #138's dump analysis couldn't - distinguish from cross-scene contamination. Each scene's own scenes. - entry must carry its own incident_type/severity, distinct from any other - scene on the same call. + distinguish from cross-scene contamination. Pins _apply_and_log's write + side: each scene's own scenes. entry carries its own incident_type/ + severity, distinct from any other scene on the same call. Does NOT cover + whether the ctx handed to _call_is_substanceless is the same object that + reaches here — that linkage is pinned by test_consensus_gate.py and + test_incident_identity.py, not this file. """ docs: dict[tuple, dict] = {} @@ -160,7 +163,7 @@ async def test_scene_entry_captures_its_own_incident_type_not_a_sibling_scenes() } ctx1 = { "call_id": "call-5", "scene_index": 1, "scene_transcript": "roll call", - "incident_type": None, "call_severity": "routine", + "incident_type": None, "call_severity": "moderate", } with patch.object(incident_correlator, "fstore") as mock_fstore: @@ -168,13 +171,16 @@ async def test_scene_entry_captures_its_own_incident_type_not_a_sibling_scenes() await incident_correlator._apply_and_log(decision0, ctx0) await incident_correlator._apply_and_log(decision1, ctx1) - scenes = docs[("calls", "call-5")]["scenes"] + doc = docs[("calls", "call-5")] + scenes = doc["scenes"] assert scenes["0"]["incident_type"] == "traffic-stop" assert scenes["0"]["severity"] == "routine" assert scenes["1"]["incident_type"] is None - # _apply_and_log never writes incident_type/severity to the flat - # top-level fields at all (only corr_debug's own keys go flat) — the - # per-scene entry is the only place either value is ever persisted. + assert scenes["1"]["severity"] == "moderate" + # _apply_and_log only ever flat-merges corr_debug's own keys (:1460) — a + # future corr_debug["incident_type"] would silently clobber + # intelligence.py's flat field, so this is asserted, not just commented. + assert "incident_type" not in doc @pytest.mark.asyncio