is_dispatch was computed from _is_dispatch_channel(talkgroup_name) and picked between two _call_fits_incident evaluation orders: dispatch (requires a positive signal, runs location-conflict/content-divergence vetoes on unit overlap) vs tactical (skips both vetoes, defaults to True on no signal at all within 20 min). Per #133's reasoning, a name not literally containing dispatch/patched/primary got the unvetoed, default-True path solely because of its label. Hardcoded is_dispatch=True at its one real call site; the tactical branch and its own tests stay in place, unreached.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tbknwttzou4s46PAykmtix
Owner correction from direct scanning experience: a talkgroup named tac/tactical only sees materially different traffic during a real incident, and that's rare -- the bulk of traffic on any monitored channel, including high-risk stops and pursuits, runs on the main channel regardless of what it's named. _is_dispatch_channel's string match on the talkgroup name is a naming-convention guess, not a detector of actual channel behavior; trusting it here meant a busy single-channel department not literally named 'dispatch' would silently get the more permissive 15-minute window and could reproduce #115's original bug (the gate never firing on the channels it targets).
Always use tg_dispatch_thin_idle_minutes (5 min) in the escape hatch, regardless of talkgroup name. Does NOT touch incident_correlator.py's own fast/thin idle-window selection, which uses the same dichotomy for a different, decision-changing purpose -- bigger blast radius, left for its own review.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tbknwttzou4s46PAykmtix
Review of #132 found a blocker: PATCH /calls/{id}/transcript wipes tags/severity/location/units/embedding before re-extraction, but not the new scenes map, and doc_set(merge=True) can only add/overwrite nested map keys, never remove one. A call corrected from 3 scenes to 1 kept scenes.1/scenes.2 with pre-correction transcripts and incident_ids forever -- corrupting the exact per-scene tally #96 exists to make trustworthy, and able to re-feed stale text into #114's summarizer fix if a stale scene's incident_id still names a real incident.
Fix: fstore.doc_update(...,{"scenes": fstore.DELETE_FIELD}) -- a real delete, not a merge over an empty map. Added fstore.DELETE_FIELD (re-exports the real firebase_admin sentinel) and stubbed it in the sandboxed test conftest, which didn't have it. Also softened an overclaiming docstring: the Firestore nested-merge behavior is verified against the doc_set wrapper's pass-through, not against live Firestore.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tbknwttzou4s46PAykmtix
Every scene of a multi-scene call correlates independently in upload.py's
scene loop, but every scene's corr_debug was written flat onto the same
shared call doc — scene 2's write silently clobbered scene 1's
corr_path/corr_consensus/etc (#96), and summarizer.py read the whole call's
raw transcript per linked call, mixing text from scenes the incident had
nothing to do with, while ignoring transcript_corrected entirely (#114).
Fix: thread a scene_index from both `for scene in scenes:` loops in
upload.py down through _correlate_with_consensus ->
incident_correlator.preview_correlation/correlate_call -> _build_context ->
ctx["scene_index"]. incident_correlator._apply_and_log now writes, in the
same Firestore call:
- the existing flat corr_* fields, unchanged (last-scene-wins, the safe
backward-compatible default for any reader that doesn't know about
`scenes` yet)
- a new nested `scenes.<scene_index>` entry with {transcript, incident_id,
corr_debug}, via doc_set(..., merge=True). Firestore's
DocumentReference.set(data, merge=True) recursively merges nested map
fields by key (documented SDK behaviour, not assumed) — a write to
scenes.1 merges alongside an existing scenes.0 instead of replacing the
whole `scenes` map.
scene_index defaults to 0 for every caller with no scene concept (the
recorrelation sweep, the no-scenes-extracted orphan-check path), so a plain
single-scene call still gets a one-entry `scenes` map equivalent to reading
its flat fields today.
admin.py's _call_summary exposes the new `scenes` list per call (each entry
carrying the same corr_* field names as the flat fields, so the two shapes
are interchangeable to the tally); the summary tally now iterates each
call's scenes-if-present, else its own flat fields, so a 2-scene call with
two different corr_path values counts as two data points instead of one
blend. New `scene_decision_count` sits next to `linked_call_count` to make
that distinction visible.
summarizer.py's _scene_text_for_incident reads a linked call's `scenes` map
to find the scene(s) whose corr_debug recorded a link into the specific
incident being summarized, joining more than one if several scenes landed
in the same incident. Falls back to transcript_corrected-or-transcript for a
call doc with no `scenes` field (predates this change) — the one-liner half
of #114, worth doing regardless since it stops raw-transcript summaries even
for old-schema docs.
Does not touch #80/#95/#102's existing ctx-threading fixes
(embedding/severity/coords/LLM-prompt-transcript) — correct as-is, out of
scope here.
Tests: 14 new (test_per_scene_call_doc.py, test_summarizer_scene_transcript.py,
additions to test_admin_debug_correlation.py) covering the merge shape,
last-scene-wins flat-field backward compat, the admin tally's per-scene vs
per-call counting (including old-schema fallback), and the summarizer's
scene-specific text selection (including old-schema fallback). Full sandboxed
suite: 364 -> 378 passed, all green.
Fixes server-26#96, server-26#114
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tbknwttzou4s46PAykmtix
82% of the classifier's backtest flags were <=5-word transcripts that already exit at skip_reason=transcript_too_short before the classifier ever ran, so shadow mode was on track to observe roughly a fifth of the real catch rate. Compute the verdict once, ahead of that check, and fold it into whichever doc_set already runs (no extra Firestore write). Also add a chatter_classifier_flagged/reason tally spanning both linked calls AND orphans in admin.py's summary block -- the target population is non-events, which land as orphans or single-call incidents, so linked alone undercounts it the same way corr_gate_veto would have without the #126 fix.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tbknwttzou4s46PAykmtix
The 2026-09-12 deploy of #126 failed instantly -- git pull on the VM hit 'No space left on device' before the deploy script could even capture a rollback target. Every deploy leaves 3 freshly SHA-tagged images that only got cleaned up by a prune gated on a failed compose pull; a failure earlier than that (like this one) never reached it. 96 of 100 local images were unreferenced, 23.76GB reclaimable, disk at 100%.
Move an unconditional docker image prune -af to the top of the deploy, before git pull, and upgrade the post-up -d prune from -f (dangling only) to -af (all unused) so stale tagged images stop re-accumulating. Both are safe: prune -a never touches an image a running container references.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tbknwttzou4s46PAykmtix
Three live measurement windows and two consensus-layer fixes (#125, #126)
converged on one decision (CORRELATION_REVIEW_0907b.md, _0912.md): stop
iterating the correlator's consensus layer, the actual lever is upstream —
a classifier in scene extraction that recognizes radio housekeeping (roll
call, bare 10-4/10-8/98 acknowledgements, unit check-ins) before it ever
becomes a scene for the correlator to judge.
Adds app/internal/chatter_classifier.py: a pure classify_chatter(transcript)
function recognizing two shapes drawn from hand-labeled examples in the
review docs, cross-referenced against the real dumps — not invented regexes.
Deliberately conservative: anything that doesn't cleanly reduce to a known
shape returns (False, None) and the existing pipeline runs unchanged.
SHADOW MODE ONLY. intelligence.extract_scenes computes the verdict next to
the existing _is_garbage_transcript / transcript_too_short gates and writes
chatter_classifier_verdict / chatter_classifier_reason onto the call doc,
but does not skip extraction. admin.py's correlation-debug _call_summary
surfaces both fields, same pattern as corr_gate_veto (#115/#126), so the
next live window can measure the real-world false-positive rate before
anything is wired to actually skip extraction. TODO(server-26#127) marks
the call site.
Backtest against all three existing dumps (1002 calls): 154 flagged, 0
false positives (no flagged call carries tags, coords, non-routine severity,
or matches any review-doc-named dangerous-to-drop transcript — the major
extinguishing-fire call, geocoded calls, pursuit updates, the Pelham Station
subject check, the property-retrieval call, all individually verified).
tests/test_chatter_classifier.py: real transcripts from the dumps/review
docs in both directions. Sandboxed pytest 332 -> 364, green.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tbknwttzou4s46PAykmtix
corr_gate_veto was written to corr_debug but the admin endpoint's whitelist (_call_summary + the summary tally) never surfaced it, so the last commit's whole point -- measuring window #4 instead of guessing -- would have produced nothing to read. Add it to both. Also softened the docstring's remaining overclaim: whether the active-only ctx[recent] limitation explains the 2/24 window-3 misses is unanswered, not confirmed -- read corr_gate_veto next window instead of asserting a guess again.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tbknwttzou4s46PAykmtix
Review of #126 found: (1) the escape hatch applied tg_dispatch_thin_idle_minutes (5 min) unconditionally, but incident_correlator's own fast/thin path only uses that on dispatch channels and 15 min on tactical ones via _is_dispatch_channel -- mirrored the same selection here, plus a config.py note flagging the second consumer. (2) the docstring claimed a 'confirmed explanation' for 2 window-3 gate misses that was actually wrong (self-contradictory in its own text); replaced the guess with corr_gate_veto, written into corr_debug on every llm=orphan/rules=new disagreement that escalates, so window #4 can see *why* each one escaped instead of reconstructing it from the raw dump.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tbknwttzou4s46PAykmtix
_recent_incident_on_same_talkgroup previously treated ANY same-talkgroup
incident within the 2-hour correlation_window_hours lookback as 'recent',
which disabled the whole LLM-orphan consensus gate on busy dispatch
channels: window #3 (CORRELATION_REVIEW_0912.md) measured 0/24 gate fires
against the exact target shape (rules=new, llm=orphan, tiebreak=new), with
22/24 explained by a same-talkgroup incident existing somewhere in the
prior 2h — nearly guaranteed on channels producing 3-13 incidents/2h.
Now the escape hatch only counts an incident as recent within
settings.tg_dispatch_thin_idle_minutes (5 min), reusing the same recency
bound the fast/thin path already uses for the 'dispatch, thin ack 10-30s
later' case this hatch exists for, instead of inventing a new constant.
Investigated the 2 unexplained misses (no same-tg incident found even by
a naive full-collection timestamp scan): confirmed ctx["recent"] is built
from status=="active" incidents with over-capacity incidents dropped
(_build_context / _drop_capped), not a full collection scan — an incident
that has auto-resolved or hit incident_max_calls/incident_max_duration
within the window is invisible to this check even though it is
chronologically recent. This does not explain the 2 misses (a same-tg
incident was absent by both checks there, so some other
_call_is_substanceless condition must be responsible), but it is a real
gap in the check as written. Documented in the docstring with a
TODO(server-26#115); fixing it needs a new, non-active-filtered Firestore
query, out of scope for this pass.
Tests: added a regression test proving an incident inside the old 2h
window but outside the new 5-minute window now correctly gates (fails on
main, passes here), plus a test proving a truly recent (<5min) same-tg
incident still escapes the gate as intended. Sandboxed pytest: 327 -> 329
passed (2 new tests), all green.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tbknwttzou4s46PAykmtix
_call_is_substanceless mirrored has_event_substance but not the creation gate's type-resolved short-circuit, so a routine-severity fire/medical call with no coords/tags/vehicles — or a reassignment (unit pulled to a new job) — could be gated to orphan where rules would open an incident. Bail out of the gate on incident_type or reassignment.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tbknwttzou4s46PAykmtix
The gate added in ca1d8fb checked rules_decision["corr_debug"] for a positive
signal, but that dict is empty at preview time for action=="new" (corr_path is
written at apply time). The check was always False, so the gate fired on real
events — replayed against corr_dump_9-7_pm.json it dropped ~36 linked calls
including a major "extinguishing fire", a moderate fire-alarm, geocoded calls
and pursuit updates.
Gate now runs against ctx (fully populated at preview time). It fires ONLY when
the call is substanceless: routine severity, no vehicle/geocode/tag, and no
incident already running on the same talkgroup. Any of those escalates to the
tiebreak instead. The substance predicate (has_event_substance) is factored out
of incident_correlator's creation gate and shared, so the two cannot diverge.
recorrelation_sweep: a call the gate parked gets a longer link-only retry budget
(10 vs 3) — the gate fires before any incident for the job exists, so the
substantive call that justifies linking can land after the standard ~6 min.
Still create_if_new=False.
incident_correlator location path: evaluate every in-radius candidate and link
the nearest that carries corroboration, instead of the first in an unsorted
`recent`. A unit-overlap location link is now tagged "location_unit_overlap" so
it stops merging into the fast path's bucket in the admin fit-signal histogram.
tests/test_consensus_gate.py: replaced the corr_debug-signal cases with ctx
substance cases (severity, coords, tags, vehicles, same-tg incident); added a
nearest-wins location test; the two location guard tests now assert they reach
the new guard. Full drb-c2-core suite 322 -> 325.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tbknwttzou4s46PAykmtix
CORRELATION_REVIEW_0907b.md measured that radio housekeeping (unit
check-ins, roll call, 10-8/10-98 clearings) is being promoted to
incidents. Every case reads corr_llm_action=orphan, corr_rules_action=new,
corr_consensus=tiebreak -> new: the cheap LLM correctly reads "not an
incident", the rules engine says `new` only because there is no incident to
link to, and the smart tiebreaker then sides with rules ~21/21. Reframing
the tiebreaker prompt (#116) did nothing. The fix is a consensus-logic gate,
not another prompt.
Fix 1 (routers/upload.py) - LLM-orphan gate in _correlate_with_consensus:
when the cheap LLM says `orphan` and the rules engine says `new` with NO
positive event signal, resolve to `orphan` and skip the tiebreak call
entirely. "No positive signal" = the rules corr_debug carries neither a
positive corr_path (unit-continuity / location / fast/disambig / fast/single)
nor a positive corr_fit_signal (unit_overlap / location_proximity). When it
does carry one, the existing escalation-to-tiebreak is kept so a genuine
event the LLM misreads as orphan still gets the second look. The resolved
outcome records corr_consensus="llm_orphan_gate" (greppable, distinct from
"tiebreak") and keeps corr_llm_reasoning / corr_rules_action /
corr_llm_action populated.
Fix 2 (incident_correlator.py) - tighten corr_path=location: the location
path linked on a bare sub-location_proximity_km (0.5 km) distance with no
unit or content check, which stitched a vehicle lockout to a station-restroom
slip and merged two different churches an hour apart. A location link now
requires unit overlap with the candidate OR a distance under a tighter bar
(_LOCATION_TIGHT_PROXIMITY_KM = 0.2 km). Pursuit incidents keep their
movement-speed-validated wide radius. A surviving location link now also
writes corr_fit_signal (unit_overlap | location_proximity), consistent with
Fix 1's positive-signal set.
Tests: new tests/test_consensus_gate.py (13 cases) - the gate resolves to
orphan without calling tiebreak on a no-signal disagreement; a unit_overlap /
location_proximity / unit-continuity / fast-disambig rules signal still
escalates; llm=link vs rules=new still escalates; the location path drops a
shared-area candidate with neither unit overlap nor tight proximity, links on
unit overlap, and links on tight proximity alone. Full c2-core suite
309 -> 322 passing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tbknwttzou4s46PAykmtix
The old //direction note ('ASC serves orderBy desc') was wrong for these query shapes and left useCalls/useIncidents/useAlerts and search_calls throwing FAILED_PRECONDITION. Declare calls/incidents/alert_events (…, DESC) to match the live DB (indexes created via gcloud 2026-09-08). Drop the misleading 'delete these duplicates' drift note.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tbknwttzou4s46PAykmtix
The deploy job SSHes to the VM (which runs as the project service account) but never touched Firestore, so rules and composite indexes regressed silently after every fix. Add a firebase-tools deploy right after `git pull`, additive for indexes, warn-not-fail on error.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tbknwttzou4s46PAykmtix
collectionGroup alert_events (acknowledged, org_id, triggered_at desc) — the index the /watch Triggered Alerts tab and the site-wide useUnacknowledgedAlerts hook require. Still needs a manual deploy; no automation exists (#51).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tbknwttzou4s46PAykmtix
The Archive page's GET /calls/search failed its CORS preflight (OPTIONS -> 405, no Access-Control-* headers). Allow the app origin(s) explicitly for the standard methods and the authorization/content-type headers.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tbknwttzou4s46PAykmtix
drb-correlation-review: ship, with two bounds the low-bar link rule needs.
1. _call_block emitted only the talkgroup NAME while _inc_summary emits
numeric tg ids, so the "same talkgroup" precondition in _RULES was
unevaluable and the low link bar applied unconditionally. _call_block now
prints "Talkgroup: <name> (id <n>)".
2. ctx["recent"] is an unordered Firestore slice with no order_by; a busy 2h
window (~40 active incidents) showed the model an arbitrary half of the
candidates. _prompt_incidents() sorts by updated_at desc before the [:20]
cap — also makes each row's idle: field monotonic.
+2 tests. Full c2-core suite green (sandboxed venv).
Review follow-ups (not blockers): _parse_response demotes an unresolvable
link to orphan (drops the call) rather than falling back to rules — now on
rising link volume; the 45% tiebreak escalation rate / smart-model cost is
untouched; _ROAD_RE swallows leading tokens so "10 Parker Street" still
won't road-overlap "Parker St".
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The 2026-09-07 measurement window (CORRELATION_REVIEW_0907.md) showed the
consensus tiebreaker was the dominant over-split driver: it ran on 45% of
calls and resolved link/orphan disagreements as "new" ~24/25 of the time,
shattering one Mohegan Park car-alarm job into 9 incidents and opening ~7
incidents from radio checks / roll calls.
Two causes, two fixes:
1. `_inc_summary` gave the model `id|type|loc|units|tags|idle` — no title,
no talkgroup. It literally could not see that two "car alarms, Mohegan
Park Ave/Avenue" incidents on TG 9560 were the same. Now includes the
incident title (the strongest same-event signal) and talkgroup.
2. `_RULES` told the model "orphan when in doubt — conservative is always
correct". For a system that over-splits, that is backwards: a wrong link
is cheap, a duplicate incident is the failure. Rewritten to: prefer link
for a plausible same-talkgroup continuation (low bar), reserve "new" for a
genuinely different event, and explicitly "orphan" non-incidents (radio
checks, roll call, 10-8/10-98, mileage logs).
Plus `_extract_road_ids` now canonicalises street-type synonyms
(Avenue→ave, Street→st, Road→rd, ...), so "Mohegan Park Avenue" and
"Mohegan Park Ave" share a road id — that one difference was splitting the
car-alarm incident.
+tests/test_correlator_115.py. Full c2-core suite green (sandboxed venv).
Bigger levers deferred to follow-ups: the consensus escalation itself (should
a cheap-LLM "orphan" ever reach a tiebreak?), a first-class road-overlap fit
signal in _call_fits_incident, geocode coverage.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
drb-correlation-review on the prior commit flagged two ways the per-scene
transcript could silently fall back to the whole-call text:
1. _build_transcript_block numbered transmissions "1." while the prompt says
"0-based indices" — a model echoing the labels it saw returned 1-based
indices, shifting every scene's slice by one. Labels are now "0." to match
the documented contract (also fixes the same latent skew in
_build_scene_embed_text / #80).
2. An empty join (bad / out-of-range / non-int indices) hit
`transcript or call_doc.get(...)` in _build_context and fell back to the
whole-call transcript — re-opening the leak exactly when indices are wrong.
The slice now falls back to this call's own whole transcript *before*
_build_context sees it, so it is never "". Non-int and negative indices
are rejected rather than raising.
Slice logic extracted to `_scene_transcript_text` with a dedicated test file
(4 cases: subset, corrected-wins, no-indices fallback, bad-indices fallback).
Call-doc fallback kept (sweep / no-scene path) per the review. Also restored
the `-> ` spacing lost in the prior commit's kwarg edit.
Full c2-core suite green: 300 passed (sandboxed venv). Still DO NOT MERGE
until the measurement window closes.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- CallSpineEntry.tsx: drop the dead `hasAudio` prop + the early `return null`
that sat between hooks in InlinePlayer (React #310 risk). Parent already
gates the mount on audio presence.
- NodeCard.tsx + nodes/page.tsx: pending-node card no longer double-fires.
NodeCard gains `linkToDetail` (default true); the pending branch passes
false so the wrapping onClick (open config modal) isn't swallowed by the
inner <Link> navigation. List view unchanged.
- trips/page.tsx: TripCard badge now buckets on end_date >= today, matching
the list's own upcoming/past split — an in-progress trip no longer shows a
"Past" badge under "Upcoming".
- trips/page.tsx, NodeConfigModal.tsx, nodes/[id]/page.tsx: tall modals get
`p-4` on the overlay + `max-h-[90vh] overflow-y-auto` on the panel so they
don't clip on short viewports (incidents' CreateModal pattern).
- lib/types.ts: IncidentRecord.units / vehicles are optional now, matching
Firestore (older docs omit them); incidents/[id] gains a `?? []` guard.
Untypechecked (no node/npm locally). next build in deploy.yml gates it.
Full list of remaining items in server-26 #109.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The last leg of the #80/#95 scene-context leak. llm_correlator._call_block
read call_doc's whole-call transcript for every scene, so on a multi-scene
call every scene's cheap-tier and tiebreaker decision was made against text
that also contained the other scenes.
- intelligence.py: each processed[] scene now carries its own "transcript" —
transcript_corrected, else this scene's segments joined, else (single scene)
the whole transcript.
- _build_context / preview_correlation / correlate_call: take a `transcript`
param; _build_context resolves ctx["scene_transcript"] from it, falling
back to the call doc (sweep, single-scene, tests) — the fallback is kept
here, unlike embedding/severity, because a scene always has real text.
- upload.py: both scene loops pass scene["transcript"].
- llm_correlator._call_block: reads ctx["scene_transcript"] (call-doc
fallback retained for test-built ctx).
- recorrelation_sweep: passes the call doc's text explicitly.
- +1 regression test. Full c2-core suite green (296 passed, sandboxed venv).
NOT for merge until the running correlation measurement window closes and its
dump is analysed — deploying a correlator change mid-window would mix old and
new behaviour in the sample.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
From a live review of drb.cusano.net.
MapView.tsx / globals.css:
- The Leaflet map painted above the sticky Nav (z-40) and modal overlays, so
on Live the account dropdown opened *behind* the map. Pin .leaflet-container
to its own stacking context (position:relative; z-index:0) — keeps Leaflet's
internal pane order, drops the whole map below app chrome. The map's own
overlay UI (legend, rail, clock, fit-all) is outside .leaflet-container and
unaffected. Chosen over raising Nav's z-index, which would float the sticky
header over modal backdrops on ~7 pages.
- Basemap: the "Dark" tile URL is already CARTO's keyless dark raster (so a
prod "API KEY REQUIRED" watermark is a stale build or CARTO rate-limiting
the origin, not this code). Add NEXT_PUBLIC_MAP_TILE_URL as a build-time
override so a keyed style drops in without a code change; add the OSM
attribution the keyless CARTO tiles require.
incidents/page.tsx, alerts/page.tsx:
- Both dumped raw Firestore "requires an index / PERMISSION_DENIED" strings
(with a console.firebase URL) straight into the UI when the composite
indexes aren't deployed (server-26 #13/#51). Collapse those known infra
failures to a plain sentence; any other error passes through verbatim so a
real bug still shows. alerts also now surfaces the events-query error at
all — it was swallowed, showing a false "No alerts triggered yet." on a
public-safety screen.
onboarding/page.tsx: stale comment (/dashboard -> "/").
Untypechecked (no node/npm locally); presentational only — one string
helper, one added error branch, a CSS rule, two tile-URL constants, a
comment. next build in deploy.yml gates it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The mint panel's copy command hard-coded --node-id node-XXX. Now the label
just entered (the operator types the node id there — placeholder relabeled
"Node ID, e.g. node-003") is captured on mint and interpolated into the
command: spaces → dashes, non [A-Za-z0-9_-] stripped (install.sh's rule),
falling back to node-XXX only if that yields nothing. The "edit node-XXX"
hint now only shows in the fallback case.
Not typechecked (no node/npm here); one useState<string|null>, one derived
string, a JSX conditional. `next build` in deploy.yml gates it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
After a node enrollment token is minted, the panel now renders the
paste-ready `curl -fsSL .../install.sh | sudo bash -s -- --token <minted>
--node-id node-XXX --c2-url <derived> --mqtt-broker <derived>` line with a
Copy button, alongside the bare token (also kept, also now copyable).
- c2-url from NEXT_PUBLIC_C2_URL (same var lib/c2api.ts reads), fallback
https://api.example.net
- mqtt-broker derived as mqtt.<api-host minus leading api.> — a DNS
assumption; the panel text tells the operator to check it
- node id is a node-XXX placeholder; the panel collects none
Pairs with node-26's install.sh (feat/one-shot-install). The raw/tag/v1/
URL resolves once v1 is re-cut at that PR's merge.
NOT typechecked here (no node/npm in this environment); plain React, two
useState booleans + one computed string, reviewed by eye. `next build` in
the deploy workflow will catch a real type error before it ships.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
intelligence.py writes only the primary scene's embedding and severity to
calls/{id}. _build_context read them back off the call doc, so every
non-primary scene of a multi-scene call was correlated against scene 1's
semantic vector and severity rung: a scene about a different event scored
on the embedding path against the wrong incident, and could inherit a
minor/moderate/major severity it never had, clearing the creation gate on
borrowed weight. Same defect and same fix as the #87 coords leak.
- _build_context / preview_correlation / correlate_call: take embedding and
severity as params; drop the call_doc.get() fallbacks. A scene that
passes none has none, and is judged thin on its own signal.
- upload.py: both scene loops pass scene["embedding"] / scene["severity"];
_correlate_with_consensus forwards them. The no-scene unclassified branch
passes neither (correct: no scene, judged thin).
- recorrelation_sweep: passes the call doc's stored values explicitly
(whole-call re-link, link-only, so a borrowed severity cannot create).
- intelligence.py: SCENE DETECTION prompt tightened toward one scene
(server-26#5, partial) - MULTIPLE only for genuinely separate events,
"when unsure, one scene", plus a not-a-new-scene list.
- test_incident_identity.py: +2 regression tests mirroring the #87 test.
Full c2-core suite green (295 passed). #5 prompt change is unmeasured -
needs a scoped correlation-only window. Known remaining legs, tracked
separately: llm_correlator._call_block still reads the whole-call
transcript per scene; content-divergence veto skips on a None embedding.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Run 570 (8b6c170) pushed c2-core successfully, then failed on
discord-bot with "failed to authorize: failed to fetch oauth token:
unauthorized" ~20s later, using the same credential. That is a
short-lived registry token expiring mid-run, not an invalid one.
Build job failure skipped "Deploy to VM", so 8b6c170 -- which closes
the viewer-triggerable OpenAI spend on incident summarize
(server-26#81) -- never reached production. Prod stayed on b722223
with the spend leak open.
No code change. This commit exists only to re-run the pipeline.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
POST /incidents/{id}/summarize was gated by require_service_or_firebase_token,
which accepts any authenticated Firebase user including role "viewer". That
route spends OpenAI credits via the background summarizer. The call-side
equivalent was already moved to require_admin_token; this brings the incident
side in line with it.
The frontend's two "summarize now" buttons on the incident detail page are
already gated behind isAdmin, so this backend change matches existing UI
behavior exactly and does not break any viewer/operator surface — it only
closes direct-API access for non-admins.
Swept every other route in incidents.py: list/get are reads with no spend and
correctly stay open to any signed-in user; create/update/delete/link/unlink
were already require_admin_token. No other sibling route needed changing.
Adds test_incident_summarize_auth.py pinning the dependency wiring directly
(the convention used in test_admin_feature_flags.py), so a future revert back
to the weak dependency fails a test immediately.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>