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>
Gate A (BUSINESS_MODEL.md, board minutes #42, dated to today by minutes #79
decision 14) blocks putting a price or an unbuilt entitlement claim on a
surface a reader can see, and requires that unverified machine assertions be
labelled as such on the same screen as the assertion.
The pricing leg was already met — /pricing and both homepage CTAs stopped
quoting the invented catalog. Condition A2 was not: a search of the whole
frontend for a "machine-generated" or "unverified" qualifier returned zero
hits. Every transcript, summary, title, location, unit list and vehicle list
is pipeline output that no human reviews, and entity-name accuracy in those
transcripts has never been measured (server-26#48) — yet all of it was
rendered to the reader as plain fact. Unqualified machine assertions about
real incidents and real people is the exposure Gate A exists to stop.
A2 — one reusable element, components/ui/MachineOutputNotice.tsx, rendered on
the same screen as the output (a footnote elsewhere does not satisfy A1's
"same screen" standard). Three variants for three shapes of surface, all
saying the same thing; the "popup" variant uses fixed grays because a Leaflet
popup is stock-white in both themes. Covered:
- incident detail: under the summary (covers summary, title, location,
units on scene/cleared, vehicles, tags) and above the call spine
- incident list: above the timeline groups
- Archive (/calls): above the transcript rows
- node detail: above the Recent Calls table
- Watch//alerts: above the events table, whose Snippet column is transcript
text and whose keyword match was made against it
- Live map: the desktop incident rail, pinned above the scroll area so it
cannot be scrolled off the screen it qualifies; the mobile drawer; the
incident marker popup; the incident-path stop popup
- /systems: the source-call transcript preview
- /features: the two marketing sections that describe the AI pipeline
A1 — components/ui/UnbuiltMarker.tsx marks a claim unbuilt inline:
- /faq: the retention answer promised 7/90/365-day windows. There is no TTL
and no deletion sweep anywhere in the product (server-26#44), so the
answer now states plainly that nothing is deleted automatically and marks
per-plan retention as not yet available.
- /settings/billing: the plan cards' claims — custom retention, SSO/SAML,
uptime SLA, data residency — are marked not-yet-available next to the plan
that makes them.
Labelling only. No retention, SSO, SLA or residency was built; no billing,
Stripe or checkout code was touched (Gate B still bars charging anyone); no
price was added anywhere; no Python was touched. Both themes verified against
the light-mode !important overrides in globals.css, which are untouched.
tsc --noEmit clean.
Refs: server-26#46, server-26#44, server-26#48
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
_build_context fell back to call_doc.get("location_coords") whenever a
scene passed no coordinates of its own. One radio call can be split
into several scenes, but only the primary scene's geocode is ever
written to the call doc — so every non-primary scene silently
inherited the primary scene's pin. That fabricated location_proximity,
the strongest accept signal the correlator has, for a scene that had
no location at all, and drove it into the primary scene's incident on
a pin it never had.
Drop the fallback: coords = location_coords. A scene with no location
is now correctly judged thin, cannot win the location path, cannot
supply call_coords to _call_fits_incident, and cannot seed
_find_cross_system_parent.
recorrelation_sweep.py, the only other caller of correlate_call, was
verified to already pass both location and location_coords explicitly
from the call doc, so the fallback there was a no-op and this change
is behavior-preserving for that path.
Adds test_a_scene_with_no_location_does_not_inherit_the_call_docs_pin
to test_incident_identity.py, pinning ctx["coords"] is None and
ctx["is_thin_call"] is True when location=None but the call doc
carries a location_coords.
Ref: server-26#87
Scaffolding for a service that does not run and is not in compose. It has sat
untracked across four unattended runs, each of which had to decide again
whether to commit or delete someone else's work. Declaring it out of scope
ends that. server-26#56.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Board minutes #62 Decision 2 (server-26#64), due 2026-08-31. CTO draft #60
finding 1 and CISO draft #61 finding 3 reached this independently.
GET/PUT /admin/features accepted only a Firebase admin token, so the unattended
runbook had no headless path and SSHed into the c2-core container to write
config/ai_features with the admin SDK. Moving a platform-wide AI cost switch
required a full container shell, and set_flags() wrote no audit entry either
way, so a flag flip was unattributable however it happened.
- New agent_service_key (AGENT_SERVICE_KEY), deliberately separate from the
Discord bot's service_key. Sharing one key would collapse two principals into
a single unattributable identity in every log line, and the bot has no
business flipping AI flags regardless.
- require_agent_key_or_admin accepts the agent key or a Firebase admin, and
rejects the Discord key. The "key is configured" guard is load-bearing:
compare_digest("", "") is a match, so a deployment that never set the key
would otherwise accept an empty credential.
- set_flags() writes an audit_log entry with before/after values and the actor,
wrapped so an audit failure cannot lose the flag write or 500 the route.
- Cascade helper sets the global doc and every system carrying an ai_flags
override in one call. A global False already beats everything, but a system
False beats a global True, so turning AI *on* could half-apply and leave a
radio system hot after shutoff. It scans for the override rather than
hardcoding the two known system IDs, so a new system cannot silently defeat
it.
- cascade defaults to False. PUT /systems/{id}/ai-flags and the AiFlagsPanel
toggle mean a per-system override is deliberate operator intent; cascading by
default would erase it on any unrelated global flip. The runbook opts in.
Issue items 5 and 6 (retiring the SSH path from drb-worksession.md) are NOT
done here and the runbook is untouched. The credential does not exist in
production yet, so the SSH path is still the only one that works; retiring it
now would break the next unattended run. Owner activation is recorded on #64.
Tests 273 -> 289.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Alert dispatch attached a 200-character raw transcript snippet to the
alert_events document and POSTed the same text to the org's Discord
webhook, with no redaction of any kind. Board minutes #42 ratified that
person names are suppressed on every surface until E&O is bound, and a
Discord channel is the least recoverable surface there is: once the text
lands we do not own it, cannot unsend it, and cannot audit who read it.
Raw transcript text now requires two independent gates, both closed by
default:
1. alert_transcript_snippet_enabled -- an operator switch in config,
set from the environment.
2. alert_snippet_opt_in on the org document -- the customer's own
explicit consent.
Gate 1 is not redundant. The frontend reads and writes Firestore directly
from the browser, so the org flag alone would let an org owner opt
themselves into receiving person names lifted from live public-safety
traffic. Capability is the operator's to grant; consent is the org's.
The gate fails closed on a Firestore error and on a call with no org
(a pre-tenancy node that has not been backfilled) -- a less informative
alert is cheap, an unrecallable disclosure is not. Alerting itself is
unchanged: the webhook still fires and still names the rule, the
talkgroup and the matched keywords.
This does not wait on the Gate B3 redactor (#43, 2026-09-30). The
snippet was a convenience field and needed no redactor to withhold.
Tests assert the person name in a sample transcript does not appear in
either the outbound payload or the Firestore write, in every combination
of the two gates.
Closes server-26#85. Refs #42, #43, #48.
Board minutes #62 decision 6d bars showing live data to a prospect until #43 is
scoped. The conversation count is 0 of 12 with a hard checkpoint on 2026-09-05,
so the scoping document is worth more this week than the implementation, which
is not due until 2026-09-30.
Corrects a premise in #43: the extraction prompt carries no person-name entity
field, so "entities are already extracted" does not hold. Redaction has to work
on raw free text, and that is most of the estimate.
Redaction is specified at write time rather than read time, because the frontend
reads Firestore directly and rules cannot mask a field -- redacting only in the
API would leave the raw document readable in the browser.
EMS exclusion needs a per-talkgroup flag. ai_flags is per-system, and real
systems carry EMS alongside police and fire.
Refs server-26#43, #42, #62, #66, #85.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
_call_fits_incident measured incident idle with the signed helper while every
other recency gate in the file uses the unsigned one. On the re-correlation
sweep, `now` is the call's own started_at, which can precede the incident's
last activity, so the value went negative.
Negative idle made `idle_min >= 15` false, which meant the content-divergence
veto never ran and unit overlap was accepted unconditionally -- on a shared
dispatch backbone that is the feedback loop that lets one incident absorb a
whole talkgroup. It also made `idle_min < 20.0` true at any back-dating, so a
tactical channel returned tactical_default for every swept orphan out to the
90-minute bound.
One variable feeds all four gates in the function, so this is a one-line change
at the source. The signed value is untouched where it belongs: callers still
compute corr_incident_idle_min themselves, so debug output keeps its meaning.
Direction is toward more splitting, on the sweep path only, which is the point
-- the bug was suppressing an over-merge veto. Forward-dated calls and anything
inside the thresholds behave exactly as before.
Two tests added alongside the existing idle-gate cases; both fail on the old
line and pass on the new one. 266 pass, 0 fail.
Refs server-26#74, #5, #80.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Dedup was failing on essentially every inbound call in production. dedup.py
queries system_id == X with a started_at range; that composite index was
neither declared in firestore.indexes.json nor present in the live c2-server
database, so the query returned FAILED_PRECONDITION, dedup swallowed it as a
warning, and every duplicate check degraded to "not a duplicate".
While AI is off that only cost duplicate call documents. With a window open it
would have paid Whisper and Gemini twice for every double-heard transmission,
and fed Gate B5's cost measurement a figure that is wrong for a reason
unrelated to the pipeline being measured. Two documents for one transmission is
also the exact input shape that produces a spurious second incident.
The index is created on c2-server and building. This declares it in source so
the file and the live database agree.
Refs server-26#84, #33, #45.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
config/ai_features was not the switch it was documented to be. Three paths
spent money with it off, and one path read it wrong, so per-system opt-outs
did not opt anything out.
- Correlation in the ingest pipeline tested the raw global flag instead of the
per-system resolution. With a system opted out, extraction was skipped but
the no-scenes fallback still correlated the call with empty tags, taking the
thin/recency path and attaching it to whatever incident was most recent on
that system. The opt-out did not disable correlation, it disabled good
correlation and left the worst kind running. (#75)
- Transcript correction ran on every transcribed call gated only by an env var,
spending Gemini tokens and a Places lookup per proposed location. An
"STT-only" window was never STT-only and its cost could not be attributed.
Now behind transcript_correction_enabled. (#76)
- _run_extraction_pipeline and the vocabulary learner, both reachable from
PATCH /calls/{id}/transcript, checked no flags at all. (#76, #81)
The flag resolver now lives in feature_flags.resolve_flags() rather than as a
local helper in upload.py. Three copies of that logic is how #75 happened.
PATCH /calls/{id}/transcript now refuses with 409 when correlation is off.
That route wipes tags, severity, location, units, embedding and unlinks the
call from every incident before queueing re-extraction. Gating extraction
alone would have made it destructive-only in the standing flags-off
configuration: the call left blank and orphaned forever, with the route still
answering 200. The wipe and the rebuild are one transaction in intent, so it
refuses before the first write.
Also: the summarizer's stale-incident sweep is no longer behind
summaries_enabled. It is pure Firestore with no model call in it, and gating
it meant nothing auto-resolved while AI was off - so every incident stayed
active forever and the candidate set every correlation reads kept growing.
transcript_correction_enabled is documented as NOT a pure cost lever. The
corrector is also the noise gate that sets not_speech; with it off, recogniser
noise reaches extraction as a real transcript, comes back thin, and
auto-attaches. Never open an evaluation window with correction off and
correlation on.
14 tests added covering flag precedence, both pipeline paths, the 409, the
correction gate and the summarizer no-op. Suite: 264 passed.
Refs #75, #76, #81, #45.
deploy.yml ran `compose up -d` before the health check and never reverted
on failure. A build that passes tests, returns 200 on /health with the
right git_sha, but has a live logic bug (exactly the class of bug the
correlator instrumentation exists to catch) would stay live indefinitely
- notify-failure would even claim production was "still running the
previous build", which is false in that scenario.
Deploy step now reads /opt/drb/.last_good_tag (written only after a prior
deploy's own health check confirmed its SHA) to capture the previously-
verified tag before switching, and emits it as a step output. Health
check is unchanged in shape (bounded 20x5s retry, still requires the
polled git_sha to match) but now persists the new SHA as the rollback
target only once confirmed live. A new Rollback step runs on any failure
above, re-deploys the previous tag, and re-verifies via the same git_sha
check rather than trusting mere liveness - then fails the job loudly
either way, since the push itself was still bad. notify-failure now
reports what actually happened (rollback succeeded/failed/skipped and to
which SHA) instead of the old unconditional claim.
This unblocks #62 decision 9: autonomous pushes to incident_correlator.py,
llm_correlator.py, intelligence.py and routers/upload.py were frozen until
this rollback path landed.
Refs #65, #62, #60, #57.
/pricing and the homepage teaser rendered the $0/$79/Custom catalog from
lib/billing.ts with a below-the-fold disclaimer. Board minutes #42 ratified
Gate A: no price on a public surface until the model is ratified and the
entitlements exist — a false price anchor with a footnote is worse than no
price. The page has been live in breach since ratification (server-26#46).
- /pricing: no numbers, no plan cards, no interval toggle. "Pricing is in
development", CTA to the existing /waitlist request-access page.
- homepage: pricing teaser replaced with the same message; PLANS import gone.
- homepage CTAs pointed at /login, which has no signup path — a real visitor
could not create an account. Now /waitlist ("Request access"); the secondary
CTA is honestly labelled "Sign in".
- lib/billing.ts: plan catalog header now states the prices are invented and
that retention/SSO/SLA have no backend, so the next person to import PLANS
is warned at the definition site.
Refs server-26#46, server-26#62. Authenticated /settings/billing is unchanged
and still stubbed — not a public price surface, stays with #46.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"Post 1-2" reached the geocoder, resolved against its talkgroup anchor and
produced a confident pin in the right town for an event with no known
location — while sitting in the same incident's `units` list the whole time.
A plausible wrong pin is worse than no pin: nothing downstream can tell it
is wrong.
Extraction returns `location` and `units` from one pass, so a string in both
is a misclassification, not two facts. Drop it before the geocoder sees it.
Closes server-26#52.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#36 — the correction pass shipped in 58efdbd was right, its reference-data
shape was not. One shape now, at both scopes, every field nullable:
area_context: { municipality?, county?, state?,
center?, radius_km?, resolved_from?, resolved_at?,
local_knowledge?: [{term, meaning}] }
`state` closes the ambiguity that made "Ossining" a national guess.
`local_knowledge` replaces roads[]/landmarks[], which could not hold
intersections, schools or nicknames and carried no meanings — `11-X-ray` is
useless alone, `11-X-ray — MTA PD patrol unit` is what a corrector can act on.
Pre-#36 roads[]/landmarks[] are read forward as bare terms so nothing an
operator already entered is lost.
Nullability is the mechanism: which scope gets filled is the operator's
declaration of how homogeneous the system is. One town — fill it once at system
level. Statewide — leave it blank and fill each talkgroup.
The backend owns the derived anchor. PUT /systems/{id} merges config.talkgroups[]
against what is stored instead of writing the client's blob verbatim, which
would have erased the anchor and the pending queue — the same defect as the
ten_codes wipe.
#37 — Maps as a verifier, not as prompt stuffing. The corrector emits its
location nouns; each is geocoded against the talkgroup's anchor, and on a miss
we look for a sound-alike that does resolve there, correct to it, and propose
{term, meaning} to that talkgroup. Cost scales with location nouns, not calls.
No anchor means SKIP. An area too wide to discriminate stores no anchor at all,
because a statewide radius would confirm anything inside it — verification that
passes everything is worse than none, since it reads as a check in the data.
Also re-anchors _geocode_location, which rejected results >40km from the NODE
(server-26#6). An antenna is not a jurisdiction; distance-from-node was always
a stand-in for the anchor and is now only the fallback.
The induction loop proposes at talkgroup level and never promotes. Blast
radius: a wrong term on a channel misleads that channel, the same term
system-wide misleads one 400km away on a statewide system.
38 new tests; 240 pass. Frontend typechecks clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Correction existed, but as a line in intelligence.py's EXTRACTION_PROMPT --
which put it in the wrong place twice over. The same model call that extracted
units, location and severity emitted the correction afterwards, so extraction
reasoned over text already known to be wrong; and it sat behind
correlation_enabled, so during a cost-controlled STT-only window nothing was
ever corrected at all. That is the normal state during development.
internal/transcript_correction.py is now its own pass, between the degenerate
filter and the Firestore write. It receives an already-produced transcript plus
a reference list, so unlike a Whisper prompt it has no series to extend -- the
distinction that keeps vocabulary out of the recogniser's prompt, where an
enumerated ten-code list once made it hallucinate ten-code runs.
Reference data is merged from the talkgroup and the system, TALKGROUP FIRST. A
system spanning several counties can have a talkgroup covering one
municipality, and that municipality's streets must not be buried under a
county-wide list. A single-municipality system is the degenerate case: populate
the system level and every talkgroup inherits it. Area context is now SET --
municipality, county, roads, landmarks, on both scopes -- rather than guessed
from talkgroup names, which is what vocabulary_learner did and which is close
to useless across multiple counties.
Segments are corrected too, not just the joined text. extract_scenes builds its
prompt from numbered segments whenever there is more than one, so a correction
that only fixed the transcript would have been discarded on exactly the
multi-transmission calls carrying the most content. Alignment is enforced: an
array of the wrong length or type is dropped whole, because scenes map back to
transmissions by index and a shifted array would misattribute audio silently.
Whisper is also retried once on degenerate output. Call e49ea32c produced a
56-word ten-code counting run on one attempt and ordinary speech on the next --
same clip, same temperature=0 -- so a hallucination is a coin-flip, and
discarding on the first bad roll threw away a recoverable transcript.
Two things found on the way:
PUT /systems/{id} wiped ten_codes on every save. The systems form sends only
{name, type, config}, and model_dump() wrote every omitted field as its default
over the top. Now exclude_unset. area_context would have been the next victim,
which is why it gets its own route alongside ten-codes rather than a field on
that payload.
Closes server-26#36.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
audio/mpeg was hardcoded at both points call audio is written and served, from
back when the node produced nothing but 16 kbps MP3. It now uploads FLAC, and a
browser will not play a FLAC body labelled audio/mpeg.
storage.py grows one extension -> Content-Type map, used by the GCS upload and
by /media. Keyed off the object's real extension, so every existing .mp3
recording keeps working with no migration -- and _safe_audio_filename already
accepted .flac, so object naming needed nothing.
Also flags what this costs: /media sends the whole body with Accept-Ranges:
none, which was fine at ~60 KB per call and is not fine at ~1.3 MB/min. Noted
at the header and in DEFERRED.md, whose stated reason for deferring Range
support was the old file size.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
/calls was a ten-line stub that redirected to /incidents, so there was nowhere
in the app to look at a call. The nav's "Archive" link led to the incident
list, and a call that never correlated was invisible entirely -- which is
backwards when correlation quality is the thing under development, because the
orphans are the evidence. Its stated blocker (Gitea #17/#18) closed weeks ago.
The page browses the org's calls newest-first over the new /calls/search route,
filtered by link state (all / orphans / linked), transcript presence, and
system, with a transcript substring search and cursor paging. A row expands to
the full transcript, a playback link minted on demand, and the correlation path
that decided it. The counts line -- how many of the loaded calls are orphaned,
how many have no transcript at all -- is the number worth watching during an
AI window.
Attribution is the point of it: attach an orphan to the incident it belongs to,
or detach one the correlator got wrong. Both go through the routes fixed in the
previous commit, so a manual attachment now actually shows up on the incident.
Admin-only. It exposes every call in the org regardless of node ownership and
carries controls that rewrite incident membership.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three backend pieces the /calls page needs, plus the fix for a debug view that
hid its data exactly when it was wanted.
GET /calls/search — paged, filterable call archive. GET /calls returns every
call in one unordered shot: fine for a node's handful of active calls, useless
as an archive. Only the org scope and the started_at ordering go to Firestore,
since that pair is the one composite index that exists; the rest filters in
Python over a bounded window, the same shape admin.py's debug route uses. The
cursor advances over the scanned window rather than the returned page, or a
sparse filter would re-scan from the same place forever.
Manual attribution. POST /incidents/{id}/calls/{id} only ever wrote the legacy
scalar incident_id, never incident_ids -- which is what the correlator writes
and what the frontend queries with array-contains. A manually attached call was
therefore invisible on the incident page it had just been attached to. It now
maintains both and marks the summary stale. DELETE is new: there was no way to
undo an attachment at all, so a wrong link was permanent.
The debug view no longer filters to AI-enabled systems by default. That filter
emptied the view the moment the flags went off, which is precisely when a
window gets reviewed -- on 2026-08-23 it fell from 100 incidents to 6 between
switching correlation off and opening the tab. ai_systems_only=true restores it.
It also returns a summary block now: corr_path / fit_signal / consensus /
llm_action tallies, transcript coverage on both linked and orphaned calls,
single-call and median-calls-per-incident for fragmentation, max span and
anything past the server-26#22 caps for merging, and the count of incidents
still carrying a fallback "— TGID" title. All of it was being recomputed by
hand from the raw payload on every review.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The file had drifted four indexes behind c2-server, so the 2026-08-23 deploy
offered to delete four live indexes and then added ASC copies of two that
already existed as DESC. Reconciled against gcloud's actual list.
Adds the two backend indexes that were live but undeclared and are genuinely
in use -- calls(status, ended_at) for recorrelation_sweep's ended-call scan and
calls(system_id, ended_at) for vocabulary_learner. Deleting either would have
broken a background loop with no frontend symptom.
Declares every index ASCENDING. Firestore scans an index in either direction,
so org_id+started_at ASC already serves the orderBy(started_at, 'desc') that
every frontend hook actually asks for; a matched ASC/DESC pair is one index of
pure write amplification on every call document. The three duplicates now left
undeclared are named in the file header so the next deploy's interactive
delete prompt has a documented answer instead of a guess.
Refs server-26#33.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
84 of the 100 incidents in the 2026-08-23 dump were titled "Ems — TGID 9048"
or "Other — TGID 9600" -- the fallback, not a description. The title is the
incident's name everywhere it appears: list rows, map pins, Discord alerts.
_create_incident builds it from a content tag and a talkgroup label, and the
label was collapsing to "TGID {id}" because talkgroup_name arrived as None.
It is a plain form field on /upload, forwarded untouched into correlation, and
the node only sends it when OP25 had the name in its loaded tags file -- which
is exactly the case C2 can cover from its own systems collection, where all 125
talkgroup definitions live.
The lookup already existed, on the other path: mqtt_handler resolved it from
the system config on call_start. So the call document held the right name while
the pipeline that titles the incident ignored it. That asymmetry is the bug.
internal/talkgroups.py is now the one implementation -- caller's hint, then the
call document, then the system config -- and both paths use it.
_run_intelligence_pipeline resolves once at the funnel /upload and
/calls/{id}/reprocess share, so the dispatch-channel test, scene extraction and
the title all see a real name. When the call document was the thing missing it,
the resolved name is written back, so the archive and the orphan panel stop
showing a bare TGID too.
Also gives fast/thin a corr_fit_signal. It is 63% of all links and was the only
path writing none, so corr_fit_signal was absent on 295 of 309 calls and the
admin debug view's distribution panel read empty -- looking broken when it was
faithfully reporting that the dominant path records nothing. It now says
thin_recency, which is what actually decided it.
Closes server-26#34. Refs server-26#35 -- the tier's 3.5% invocation rate is a
cost/benefit question, not a bug, and stays open.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
firebase.json declared rules and indexes with no database key, so the CLI
deploys them to (default). This project does not use (default) -- c2-core
reads FIRESTORE_DATABASE and the frontend reads NEXT_PUBLIC_FIRESTORE_DATABASE,
both c2-server in production, and the index-required errors the browser prints
name /databases/c2-server/ outright. A deploy without this key reports success
and changes nothing the app can see, which is a bad way to find out.
Refs server-26#13.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three of the app's routes were referenced but never existed, so the redesign's
navigation pointed at 404s from several directions.
/dashboard was the post-login and fallback redirect target in nine places --
login, onboarding, middleware, the admin/nodes/systems/tokens/settings guards,
and the marketing header -- but app/dashboard/ was never created. Signing in
normally dropped the user on a 404. The real signed-in home is "/", which
app/page.tsx already renders as LiveView for an authed user with an org, and
which the nav labels "Live"; all nine now point there.
Nav also linked /watch and /network, neither of which existed. /watch is the
alerts screen under its redesign name, so it re-exports app/alerts/page.tsx
and /alerts stays reachable for old links. /network is new: the "my equipment"
hub the redesign moved /nodes, /systems and /tokens behind and then never
built, which had left /systems and /tokens with no entry point in the UI at
all. Its hooks all run before the admin/operator guard, per d041c86.
Separately, the admin page's guard read isAdmin without authLoading, so every
cold load of /admin -- typed URL, hard refresh, bookmark -- redirected away
while the Firebase claims were still resolving. Admin was only reachable by
clicking through from an already-mounted page. Now it waits, like every other
guarded route does.
And /incidents no longer lies about an empty list: a failed Firestore query
leaves `incidents` empty just as a quiet night does, and the page was printing
"No incidents recorded yet" over the top of a missing-composite-index error.
useIncidents already returned `error`; the page just ignored it. It now renders
an ErrorBanner instead, so the undeployed indexes in server-26#13 read as a
failure rather than as silence on the radio.
Closes server-26#30, server-26#31. server-26#13 stays open -- the rules and
indexes still have to be pushed to the live project by hand.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The build-stamp health check added in 8fbfe7d worked on its first run, and
what it caught was not a stale container -- it was a race. Runs 544 and 545
overlapped; both deployed :latest, 545's images won, and 544's health check
correctly reported that the build serving traffic was not the one it had just
deployed.
That is a real hazard, not a false positive: with :latest, two pushes landing
close together means whichever finishes last silently wins for BOTH, and
neither run's log tells you which code is actually live. Pushes land close
together constantly here.
docker-compose.yml already resolved images as ${TAG:-latest}, so the fix is to
export TAG=<commit sha> for the deploy. Each run now pulls and starts exactly
the images it built, rollback becomes "deploy a different tag", and the health
check's assertion becomes meaningful rather than order-dependent. A manual
`docker compose up -d` on the VM with no TAG set still falls back to :latest,
which is the intended escape hatch.
Also replaces the health check's single `sleep 20` with a poll of up to 100s
that stops as soon as the expected SHA appears. A fixed sleep is either too
short -- flaky red runs -- or wastes time on every deploy, and a check that
cries wolf gets ignored, which is exactly the failure this job exists to stop.
Refs logan/server-26#21