Rewrite components/ui/* (Button, Card, Badge, PageHeader, EmptyState,
Skeleton) against the chunk-1 tokens instead of hardcoded gray-9xx classes,
and drop the remaining font-mono from label/heading text.
Add the three colour-blindness-validated encoding components from
UI_REDESIGN.md §2.3:
- components/marks/SeverityMark.tsx — glyph (filled triangle / outline
triangle / outline circle) + optional spine + optional label, from the
four-level severity ladder. Colour is never the only channel.
- components/marks/TypeGlyph.tsx — five stroked SVG glyphs (fire, police,
ems, collision, other) in currentColor. Incident type is now shape, not
hue, since five hues can't clear an all-pairs CVD gate.
- components/marks/NodeMark.tsx — diamond at four weights (filled+ring /
filled / hollow / hollow-dashed). Green is gone from node state entirely.
lib/severity.tsx now renders through SeverityMark; SEVERITY_COLORS reads
the validated sev-moderate/sev-major tokens with routine/minor neutral.
IncidentBadges.tsx's TypeBadge is reimplemented on TypeGlyph instead of a
coloured pill.
Per UI_REDESIGN.md chunk 2.
Replace hardcoded dark-palette Tailwind classes with semantic CSS custom
properties (page/surface/raised/line/ink/accent/sev-moderate/sev-major/
map-*) defined on :root (light) and .dark (dark), wired through
tailwind.config.ts theme.extend.colors. Add IBM Plex Sans/Mono via
next/font/google: sans for everything a person reads, mono reserved for
machine identifiers only. Drop font-mono from body and Button's base
classes. Existing !important light-mode overrides kept temporarily so
nothing goes unreadable mid-migration (removed in chunk 4).
Per UI_REDESIGN.md chunk 1.
Both pages crashed to a blank "client-side exception" screen in production.
React error #310: the useState calls sat *below* `if (authLoading || (!isAdmin
&& !isOperator)) return null`, so the first render returned before reaching
them and the next render, once auth resolved, ran more hooks than the previous
one. React tracks hooks by call order and refuses.
The guard itself is fine and stays where it is -- only the hook declarations
move above it. Behaviour is unchanged for a user who passes the guard, and a
user who fails it still renders nothing before the effect redirects them.
Found by walking the deployed site: /nodes and /systems were the only two
routes that failed outright rather than merely showing empty data. The empty
data everywhere else is the org_id backfill, which is a separate problem.
npx tsc --noEmit clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
/admin/debug/correlation built its call lookup as {doc["call_id"]: doc}, which
raises KeyError on any stored call missing that field -- and at least one in
production is missing it. One bad document took down the entire view rather
than dropping a single call from it.
The document id is authoritative and always present; the call_id *field* is
written by the upload path and evidently has not always been. Keying off the id
we asked for removes the dependency on the field entirely.
Found while generating a correlation dump server-side, because the UI route this
serves has been unusable tonight.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Google sign-in fails in production: the popup opens, flashes, closes, and the
page shows a generic failure with nothing in the console or the network tab.
The app is served from drb.cusano.net while signInWithPopup opens its handler on
the project's firebaseapp.com origin. Chrome partitions third-party storage, so
the popup cannot read back the state its opener wrote and dies immediately.
Visiting the handler directly says so: "missing initial state ... a
storage-partitioned browser environment". Nothing about authorised domains or
the build was wrong -- the shipped bundle carries the correct apiKey and
authDomain, which is exactly what made this look like a code bug.
Caddy now proxies /__/auth/* on the bare domain to the Firebase Hosting origin,
rewriting Host so Firebase recognises the request. Same-site again, which is
Google's documented fix. The vhost becomes a `route` so the handler matches
before the catch-all proxy to Next.
The upstream host is a jinja default rather than a group_vars entry because
group_vars/all.yml is gitignored; override it there if the project ever moves.
Two manual steps remain, and all three parts are required or nothing changes:
the CI secret FIREBASE_AUTH_DOMAIN must become drb.cusano.net with a frontend
rebuild, and drb.cusano.net must be an authorised domain in the Firebase
console. This template also needs an ansible run -- CI alone will not deploy it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Redirect chain traced across middleware.ts, ChromeSwitcher.tsx and
AuthProvider.tsx before touching anything, per the ask. Those three were
already correct as of c7f985d/2a1d52b/83416fe (middleware exempts
/onboarding and /signup from the drb_session cookie gate, ChromeSwitcher
sends any signed-in no-org user to /onboarding, AuthProvider only sets the
cookie once an org_id claim exists). The actual loop was one file upstream
of all three: app/login/page.tsx hardcoded `router.push("/dashboard")`
after both the email/password and Google handlers resolved. That push
races AuthProvider's async onAuthStateChanged -> getIdTokenResult ->
cookie decision. For a no-org account the cookie never gets set, so
middleware bounces the very next request back to /login with no
explanation — the ping-pong the coordinator saw live.
Fix: login page no longer navigates from the handlers. It waits on
AuthProvider's own `loading`/`orgId` and redirects once claims are
settled (/dashboard with org_id, /onboarding without). This also fixes a
second case: a user who lands on /login already signed in (e.g. bounced
there by middleware while their Firebase session was still valid) now
gets routed the same way instead of sitting inert on a login form with no
feedback. /onboarding itself (org-name form, single action) was already
adequate as the "explain the state" screen once the loop stopped
recreating it.
Also, live tonight: Google sign-in was failing outright in prod with no
console/network trace. app/login/page.tsx's Google handler did
`catch { setError("Google sign-in failed. Try again.") }` — no binding,
error discarded. Added lib/authErrors.ts: logs the raw error, and maps
Firebase codes to messages that distinguish two categories — the user's
own situation (popup blocked/closed, bad password, network) says "try
again"; deployment misconfiguration (auth/unauthorized-domain,
auth/operation-not-allowed) says so explicitly and does not suggest
retrying, since retrying can't fix a missing authorized-domain entry or a
disabled provider. Applied to both handlers in login/page.tsx and both
in signup/page.tsx (same swallowing pattern, same fix). Per the
coordinator's steer: this is diagnosis only — no popup-to-redirect
fallback, no auth method change. If production is hitting
auth/unauthorized-domain, that's a Firebase Console fix
(drb.cusano.net -> Authorized domains), not a code fix.
Nav.tsx: sign-out was only reachable from /profile. Added a profile
dropdown (desktop) and drawer entries (mobile) with Profile / Refresh
access / Sign out, so sign-out is reachable from anywhere in the app.
"Refresh access" calls AuthProvider.refreshClaims() (already existed,
already used by /onboarding after signup) so a user whose role or org
was just changed server-side can pick it up without a full logout.
Decision on unknown Google accounts (point 4): kept self-serve org
creation via /onboarding rather than a "request access" pending state.
BUSINESS_MODEL.md #2.1 already answers this for the owner: "a limited
free public tier *and* full paid access without contributing... cash is
the primary revenue line from day one." A pending-approval gate would
contradict that — it would make org creation itself the thing being
gated, when the model explicitly does not want contribution (or approval)
to be the only door. Self-serve org provisioning via POST /auth/signup
was already built for this (2a1d52b) and needed no further gating
decision, just for the loop in front of it to stop.
Reversible: no schema change, no new gating, no billing/Stripe touched.
Bench: rsync'd to the WSL-native ~/drb-frontend workspace and ran
`npx tsc --noEmit` there (per CLAUDE.md — the H: drive install path is
not viable) — exit 0, no errors. No Python touched this pass.
/admin/debug/correlation read every incident ever created, sorted them in
Python and kept 20, and separately pulled every call in the orphan window with
no cap. That worked while the collections were small. They are not small now:
Firestore kills an unbounded scan with a 503 and the request never returns, so
the debug view simply spins -- which is also what made the org backfill script
fail earlier tonight, same cause, different caller.
Incidents now come back pre-sorted from Firestore with a limit, and the orphan
scan is capped at 3000 documents. Both queries order on the single field they
already filter or sort by (updated_at, ended_at), so neither needs a composite
index -- worth preserving, since the index file from the tenancy work has not
been deployed.
Capping introduces a way to be wrong quietly: a truncated window looks exactly
like a quiet night. The payload now carries incidents_window_exhausted and
orphan_scan_truncated so a short result announces itself instead of being read
as a correlation improvement.
The AI-system filter still runs in Python, so the incident window is 10x the
requested limit rather than the limit itself.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A bare .stream() over calls/ returned 503 "Query timed out. Please try either
limiting the entities scanned", which the Firestore client then re-raised as an
AttributeError from its own retry path -- so the real cause was only visible in
the chained traceback. The collection has simply outgrown a single scan.
Both passes now walk each collection in 500-document pages ordered by document
id, which needs no composite index. The counting pass also stops building a
list of every document just to count the ones missing org_id.
Documents written mid-run may be missed or seen twice; neither matters, since
post-tenancy code stamps org_id at write time and the update is idempotent, so
a second run cleans up anything the first skipped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The script initialised firebase_admin from a hardcoded gcp-key.json path and
then called a bare firestore.client(). Production has neither: the server is a
GCE instance using Application Default Credentials, and the app talks to
FIRESTORE_DATABASE=c2-server, not "(default)".
The credentials half failed loudly. The database half would not have: the
script would have scanned an empty (default) database, found nothing to
backfill, created the founding org there, and printed a clean success while the
real data stayed untenanted and invisible.
Both now read the same environment the app reads, and the chosen database is
printed before any work so a wrong one is visible in the dry run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Transcription is the top of the pipeline and it fails soft: any exception logs
a WARNING, returns None, and upload.py carries on. That is the right behaviour
for a network blip and exactly the wrong behaviour for an unpayable account,
because with no transcript there is no extraction, no correlation and no
incident -- the system keeps accepting calls and quietly stores empty ones,
which looks like quiet radio traffic rather than an outage.
This is the third instance of the same failure mode today. The Gemini
correlator was down first on a retired model ID and then on a depleted
balance, and in both cases the only signal was a per-call WARNING that read as
noise. The OpenAI balance is low enough that this one is a matter of when.
Billing-shaped errors (insufficient_quota, billing, credit, quota exceeded)
now log once at ERROR, name what is dead downstream, and link the top-up page.
Everything else keeps the existing per-call WARNING.
No new environment variables, so CI deploys this without an ansible run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
SAAS_PLAN.md B7. "admin" meant two different things before this: platform
operator (SAAS_PLAN.md's own framing) and, by accident of how
app/settings/layout.tsx was gated, the only role that could ever reach an
org's own billing/members/node-ownership settings. A paying customer who is
their own org's owner couldn't reach their own Settings page - the gate
checked isAdmin, which only platform admins ever have.
settings/layout.tsx now admits org_role === "owner" as well as platform
admins (isAdmin stays valid too, for support access to any org's
settings). Nav.tsx shows the Settings link on the same condition, and moves
Admin (the platform-operator screens: feature flags, users, audit,
correlation debug) out of the customer-facing link group entirely - it was
already gated server-side, this is just the nav no longer implying it's
part of the product.
Trips - an internal utility feature riding along on this stack, not a
tenant-scoped product surface (see [[trips-feature-intentional]]) - drops
out of the customer-facing viewer link group and only shows for the
founding org (new lib/tenancy.ts mirrors app/internal/tenancy.py's
FOUNDING_ORG_ID) or a platform admin, matching the mutation-route gating
routers/trips.py already got in the backend tenancy commit. Reads stay
open to any signed-in user, same as before - trips' own visibility model
(public/private per trip) predates and is unrelated to org tenancy, and
restricting it further wasn't asked for.
Also closes two DEFERRED.md items now that they have somewhere to write to:
app/settings/organization's "Save changes" button now actually calls
c2api.getOrg()/updateOrg() (routers/org.py, shipped in the backend tenancy
commit) instead of being permanently disabled. app/settings/nodes gained an
EnrollmentTokensPanel (mint/list/revoke against the same commit's
/org/enrollment-tokens routes) - without this, B2b's whole point (a
customer enrolls their own node with their own token instead of an
admin-issued key) had no way to actually be used outside a raw API call.
Left alone, and written up as new DEFERRED.md entries instead of guessed
at: node/system *write* routes (approve, create, delete) stay
platform-admin-only rather than being loosened to org owner/operator - a
real gap per SAAS_PLAN.md 2.4, but a separate authorization design that the
plan's 12-item build order doesn't enumerate. And settings/members +
settings/nodes' ownership table both still call GET /admin/users
(platform-admin-only) - a pure org owner who reaches the page via this
commit's gate will get 403s from it. Today's only real user is also a
platform admin, so this is invisible until a second, non-admin org owner
exists.
Typecheck: clean (tsc --noEmit via the WSL-native ~/drb-frontend copy).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
SAAS_PLAN.md B5/B6, narrowed: no Stripe/pricing/tier work of any kind this
pass (a mid-build correction from the business side landed while this was
in progress - the commercial model, SAAS_PLAN.md section 6.1, is still
undecided), so app/pricing and lib/billing.ts's PLANS are untouched here.
What's left of B5/B6 without that - real legal pages and a working
waitlist - still ships.
app/terms/page.tsx and app/privacy/page.tsx are section scaffolding, not
legal text. Every section is a TODO(legal) note describing what that
section needs to cover, and the page leads with a "Draft - not yet in
force" banner. This isn't caution for its own sake: DRB records, stores,
and transcribes public-safety radio traffic, and recording/rebroadcast
legality varies by state (SAAS_PLAN.md section 6.3) - an agent-generated
draft here would be actively wrong to publish, not just unpolished. Both
were pre-added to middleware.ts's PUBLIC_PATHS and ChromeSwitcher's
MARKETING_PATHS two commits ago; MarketingFooter now links both.
app/waitlist/page.tsx is a real, working form against the already-shipped
POST /waitlist - email + optional org name/note, no plan or price
mentioned anywhere on it, matching the backend route's own scope (rate
limited by source IP, not coupled to any tier). Linked from
MarketingFooter as "Request access," not from the pricing page - pricing
CTAs stay exactly as they were.
Typecheck: clean (tsc --noEmit via the WSL-native ~/drb-frontend copy).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Correcting the model IDs got past the 404s and straight into 429 "Your
prepayment credits are depleted" on every call, so the LLM correlation tier is
still down -- same symptom, different cause, and the previous commit would have
logged it as an ordinary per-call WARNING and buried it exactly like the last
one.
An empty balance shares a status code with an ordinary rate limit but is the
opposite kind of problem: a rate limit clears on its own, a dead account never
does. The match is on the billing wording ("credits are depleted",
"prepayment", "billing") rather than on 429, so a burst of rate limiting still
reads as WARNING while an unpayable account escalates to the once-per-model
ERROR that names the fix.
The two escalation paths now share _log_tier_down, which is also where the
once-per-model suppression lives -- this code runs on every call at radio
traffic volume, so an ERROR per call would be its own kind of noise.
38 correlator tests still pass. No new environment variables, so CI deploys
this without an ansible run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
SAAS_PLAN.md 2.2: there was no /signup page. The only self-serve path was
Google sign-in on /login, which auto-provisions a Firebase account with no
role or org claim at all - previously that meant "viewer role, full read
access" the moment the AuthProvider cookie logic (previous commit) let it
through. That's closed now regardless; this commit is the other side of it
- giving people an actual way in.
app/signup/page.tsx: email/password (createUserWithEmailAndPassword) or
Google, same visual language as /login. It only creates the Firebase
account - org naming is deliberately not on this page, so every path that
produces an account with no org (this one, and Google-via-/login) converges
on the same next screen.
app/onboarding/page.tsx: that screen. Shown to any signed-in user with no
orgId (ChromeSwitcher's redirect, previous commit), collects an org name,
calls the new c2api.signup() -> POST /auth/signup (routers/links.py,
already shipped), then refreshClaims() to force-refetch the ID token so
orgId picks up immediately and the same redirect effect sends them on to
/dashboard - no manual reload needed.
lib/c2api.ts also gained getOrg/updateOrg and the enrollment-token
mint/list/revoke calls (routers/org.py, already shipped on the backend)
and joinWaitlist (routers/waitlist.py) - none consumed yet, wired in ahead
of the settings/legal commits that use them so this stays one add per
concept rather than scattering client additions across later commits.
/login gained a "Don't have an account? Sign up" link to /signup. This is
signup plumbing, not marketing copy - pricing/plan copy (app/pricing,
lib/billing.ts) is untouched in this pass, that's a separate, still-open
decision (SAAS_PLAN.md section 6).
Typecheck: clean (tsc --noEmit via the WSL-native ~/drb-frontend copy).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Frontend half of SAAS_PLAN.md B2/B3. The backend commits so far (org_id
stamping, Firestore rules) don't protect anything by themselves - every
hook in lib/use*.ts reads Firestore directly from the browser
(onSnapshot(collection(db, ...))), which is why B1's rules commit called
this out as the actual read path in the first place. Until these hooks
filter by org_id, the rules just turn "any signed-in user sees everything"
into "any signed-in user sees nothing" the moment they're deployed, because
nothing supplies the org_id the rules now require.
useCalls (all three exports), useIncidents (useIncidents +
useActiveIncidents), useNodes, useSystems, and useAlerts (both exports) now
pull orgId from AuthProvider and add where("org_id","==",orgId) to their
query. If orgId is falsy - not yet resolved, or the account genuinely has
no org - each hook returns empty rather than falling back to an unfiltered
query, which would silently reopen the exact leak this closes for anyone
whose claim hasn't loaded yet. useIncident/useNodes single-doc-by-id reads
and useTrips are intentionally untouched: single-doc reads are already
covered by the rules directly, and trips has no org_id at all (see the
previous commit's trips.py gating - it's staying founding-org-only via B7,
not becoming tenant-scoped).
AuthProvider grew orgId/orgRole state (read from the org_id/org_role custom
claims POST /auth/signup sets) and a refreshClaims() escape hatch for the
signup flow to force a claims refetch after provisioning. The load-bearing
change is in when it sets the drb_session cookie: only when a claim carries
org_id. A signed-in user with no org - the accidental-signup hole
SAAS_PLAN.md 2.2/2.3 flagged, where Google sign-in on /login auto-creates a
Firebase account with no role or org claim at all - now gets no cookie,
which starts them at "no data, by construction" rather than "viewer role,
full read access" once combined with the rules deployed earlier.
ChromeSwitcher carries the other half of that guard: a signed-in user with
no orgId, anywhere outside the marketing pages, gets redirected to
/onboarding (added to the frontend in the next commit) instead of letting
every page's data hooks just quietly return empty forever. middleware.ts
adds /signup and /onboarding to a new no-cookie-gate list, since
AuthProvider's cookie logic means an unprovisioned user by definition has
no drb_session cookie - gating those two routes on it would bounce exactly
the users who need them back to /login before the client-side redirect
above ever runs. /terms and /privacy (next-next commit) are pre-added to
both PUBLIC_PATHS and ChromeSwitcher's MARKETING_PATHS here so that commit
doesn't need to touch routing files.
Typecheck: clean (tsc --noEmit via the WSL-native ~/drb-frontend copy).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous commit shipped Firestore rules that reference an org_id claim
nothing issues yet, and an org_id filter nothing writes yet - this is the
commit that makes both real. Backend half of SAAS_PLAN.md B2/B2b/B2c.
Data model: organizations/{org_id} and org_members/{uid} are new
collections (models.py OrganizationRecord/OrgMember). org_id is now an
Optional field on NodeRecord, SystemRecord, CallRecord, IncidentRecord,
AlertRule, and AlertEvent - optional because every existing document
predates it; scripts/backfill_org_id.py (written, not run - it touches
production Firestore and Firebase Auth claims) is what closes that gap
later. plan_id/subscription_status/stripe_* on OrganizationRecord are
deliberately None: no billing or pricing model has been decided, so this is
a seam, not a promise. app/internal/tenancy.py holds FOUNDING_ORG_ID, the
org every pre-tenancy document and every legacy enrollment path resolves
into.
Where org_id comes from, end to end: a customer's node enrolls with a
per-org token (new enrollment_tokens/{token_hash} collection, minted via
POST /org/enrollment-tokens - new routers/org.py) instead of the old
fleet-wide ENROLLMENT_TOKEN, which still works as a fallback that resolves
to FOUNDING_ORG_ID so an already-deployed node's .env doesn't start failing
today. The node's org_id then flows onto every call it produces
(mqtt_handler.py's call_start/call_end, upload.py's /upload handler all
resolve it from the node doc), and onto every incident correlated from
those calls (incident_correlator.py's _create_incident/_create_master_incident).
That last one is the part that isn't just a read filter: _build_context's
`all_active = collection_list("incidents", status="active")` fed every
correlation candidate - fast-path talkgroup match, unit-continuity,
disambiguation - from the entire incidents collection, unscoped. Without
scoping it to the call's own org_id, a call from org A could link into an
incident org B already owns, which is a cross-tenant data merge at
correlation time, not just an over-broad read. Same shape of bug in
alerter.py: rule matching pulled every enabled alert_rule regardless of
org, so org A's keyword rule could fire (and POST org A's Discord webhook)
on org B's radio traffic. Both now resolve org_id from the call doc itself
rather than threading a new parameter through every caller.
Every list/get route gained org scoping via a new resolve_caller_org_id()
helper in internal/auth.py, which handles the three credential shapes those
routes accept (service key, node api_key, Firebase user) uniformly and
returns None (unrestricted) for the service key and platform admins -
preserving today's single-org behaviour exactly while closing the leak for
everyone else: GET /nodes, /systems, /calls, /incidents, /alerts,
/alert-rules. Write routes for nodes/systems (approve, create, delete, etc.)
deliberately stay platform-admin-only for now rather than being loosened to
org-owner/operator - that's a real gap called out in SAAS_PLAN.md 2.4's
"should be" column, but it's a separate authorization redesign the 12-item
build order doesn't actually enumerate, and doing it half-considered here
risked being exactly the "half-applied filter is worse than none" failure
mode the plan warns about. Today's founding org keeps working unchanged;
loosening node/system management to org owners is follow-up work, flagged
rather than guessed at.
Also closed the four spend/access-attack routes SAAS_PLAN.md B2c called out
by file and line: POST /calls/{id}/reprocess is now admin-only (was any
signed-in viewer looping the Whisper+Gemini pipeline for free - DEFERRED.md
had this as a live, independent-of-SaaS exploit) plus a per-call rate
limiter as a second guard; POST /alerts/{id}/acknowledge now checks the
alert's org_id; GET /admin/features moved from require_firebase_token to
require_admin_token; and trips.py's four unauthenticated mutation routes
(create_trip, update_trip_tags, create_event, update_event) are now
restricted to the founding org (or the bot's service key, or a platform
admin) - trips has no org_id of its own and isn't getting one, since
[[trips-feature-intentional]] says it's an internal utility riding along on
this stack, not a tenant-scoped product surface.
New public-but-scoped seam: POST /auth/signup (routers/links.py, alongside
the existing /auth/link* routes) provisions an organizations doc and an
owner org_members doc for a just-created Firebase user, then sets their
org_id/org_role claims - idempotent, so a double-submit doesn't create two
orgs. This is the only route that turns "has a Firebase account" into "can
read anything," which is what the frontend AuthProvider no-claim guard
(next commit) is built around.
Also new: GET/PATCH /org for the organization profile (closes the disabled
"Save changes" button noted in DEFERRED.md - there was no organizations
concept to save into before this), and POST /waitlist (public, source-IP
rate-limited, not coupled to any plan or tier - the commercial model is
still an open decision per SAAS_PLAN.md section 6).
Verified: all touched files py_compile clean; c2-core pytest is 69
passed / 10 failed, matching the documented pre-existing baseline exactly
(DEFERRED.md - mqtt_handler/node_sweeper test-vs-code drift, unrelated to
this change) - no new failures. flake8 --max-line-length=120 shows no new
violations in any touched file (checked each new E501/E221/E30x against
`git diff` to confirm it predates this commit); c2-core has no CI lint gate
regardless (CLAUDE.md - flake8 only runs in Client CI).
No new environment variables. Firestore composite indexes for the queries
this introduces were already shipped in the previous commit
(infra/firestore/firestore.indexes.json).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both Gemini model IDs had been retired by Google. Production logs show every
correlation call 404ing -- "models/gemini-2.0-flash is no longer available" --
and gemini-1.5-pro is gone from the model list as well. Because a failed LLM
call falls back to the rules decision by design, nothing surfaced: the pipeline
kept producing incidents, so the LLM tier and the consensus tiebreak were dead
in production for an unknown number of days while correlation was being tuned.
Some of what recent tuning was reacting to was rules-only behaviour that was
never meant to run alone.
Cheap model becomes gemini-3.6-flash, which is the migration target named in
Google's own 404. Smart model becomes gemini-2.5-pro, the only stable Pro-tier
text model left; the tiebreak fires rarely and its value comes from being a
different, stronger model than the first pass, so a second Flash was not worth
the consensus it would give up. Model list checked against
https://ai.google.dev/gemini-api/docs/models on 2026-08-18.
The more important half is the logging. A per-call WARNING was the only signal,
and it is indistinguishable from an ordinary API hiccup, so a permanent
misconfiguration read as noise. Failures that look like a missing model (404,
"not found", "no longer available") now log once per model at ERROR, name the
config keys to change, and say plainly that correlation is running rules-only.
Transient errors keep the old per-call WARNING. Once per model, not once per
call, so the alert stays readable at radio traffic volume.
Gemini is used nowhere else in c2-core -- extraction, embeddings and summaries
all run on OpenAI -- so the blast radius was exactly the correlation LLM tier.
38 correlator tests still pass. No new environment variables: both model IDs
are config.py defaults and are not templated into any .env, so CI deploys this
without an ansible run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Dispatch audio names the same unit several ways within one conversation, and
every comparison in the correlator used exact string equality, so a follow-up
transmission from a unit already on an incident simply failed to find it. With
the creation gate no longer letting routine traffic open its own incident,
these stopped becoming junk incidents and started becoming orphans instead --
which is how they became visible. In the 01:05Z dump, five of eighteen orphans
were calls belonging to an incident that was open at that moment:
"K-9A2" vs "K-9-A-2" punctuation
"5-1-6" vs "516" digits read out individually
"37" vs "37th Post" ordinal plus role word
"11-Victor" vs "11 Victor" hyphen vs space
_normalize_unit lowercases, drops punctuation and role words (post/unit/car),
strips ordinal suffixes, and joins the remaining tokens, so each pair above
collapses to one key. All six comparison sites now go through it: the two
fast-path debug reporters, unit-continuity candidate selection and its
reassignment check, the cross-talkgroup 2+ shared-unit test, and the
disambiguation scorer.
What it deliberately does NOT do is match a bare district letter -- "Adam" is
not treated as "6-Adam". Every district has an Adam, and collapsing them would
merge unrelated incidents across districts. That leaves a couple of the
observed orphans unlinked, which is the right trade: a missed link leaves an
orphan the re-correlation sweep retries three times, while a false link
corrupts an incident permanently and nothing walks it back.
Two smaller things fall out of the shared helper. Matches are reported as the
original spoken strings rather than the normalised keys, so corr_matched_units
stays readable in the debug view. And a unit made only of role words ("Post")
would normalise to the empty string and then compare equal to every other such
unit, so it falls back to the raw text -- tested, because that failure would be
silent and would merge aggressively.
Adds 13 cases: each observed pair, five pairs that must stay distinct, the
empty-key guard, match reporting, and an end-to-end check that the K-9A2 call
now links where it previously orphaned. 38 pass.
No new environment variables, so CI deploys this without an ansible run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
SAAS_PLAN.md's review found the actual finding underneath "no multi-tenancy":
drb-frontend reads Firestore directly from the browser (every hook in lib/
does onSnapshot(collection(db, ...))), so drb-c2-core/app/internal/auth.py
is never in that read path at all. Whatever rules were protecting calls,
incidents, and nodes had been hand-set in the Firebase console -
unversioned, unreviewed, and invisible to anyone reading this repo.
Added infra/firestore/firestore.rules: deny-by-default, with every
tenant-scoped collection (nodes, systems, calls, incidents, alert_events,
alert_rules) gated on resource.data.org_id == request.auth.token.org_id, an
org_id claim that doesn't exist yet - the next commits add it. All client
writes stay denied; c2-core's admin SDK bypasses rules and remains the sole
writer, which was already the architecture. Secret-bearing collections
(node_keys, the new enrollment_tokens) are denied to clients outright rather
than org-scoped, since nothing should ever hand a raw credential to the
browser. trips/trip_events keep their current "signed-in users can read"
shape rather than being pulled into org scoping - that feature isn't
tenant-scoped in this pass (see B7), just hidden from non-founding-org users
in the UI.
Added infra/firestore/firestore.indexes.json for the composite indexes the
org_id-scoped queries will need once the frontend hooks add the equality
filter alongside their existing orderBy/range/array-contains clauses -
without these, those queries fail at runtime with a FAILED_PRECONDITION
"index required" error rather than at review time.
Also extended internal/firestore.py's collection_where() with optional
order_by/limit_to/start_after params (SAAS_PLAN.md item 1, a stated
prerequisite for B2: scoped queries need to stay ordered and bounded, and
the existing helper could only do unordered full-collection scans).
array_contains needed no new code - it was already a pass-through op string
to FieldFilter.
None of this is live yet. Deploying rules/indexes is a manual step
(firebase deploy --only firestore:rules,firestore:indexes --project
<project-id>, from infra/firestore/) - nothing in CI does this. Until it
runs, the console-configured rules are still what's actually enforced, and
these rules reference an org_id claim no token carries yet. Deploy this
alongside (not before) the org_id-stamping commits that follow, or every
read breaks for the current single-org deployment.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
With over-creation fixed, the incidents that remain are readable enough to
judge, and the ones that still do not make sense all fail the same way. A
content-free call attaches to the single active incident on its talkgroup if
that incident has been idle under tg_dispatch_thin_idle_minutes, and at 10
minutes that is long enough for the channel to have moved on to something
else. In the 00:30Z dump a "72 at Holland Station" incident absorbed a Grand
Central train-crew meet 9.6 minutes later, and a status check absorbed a
records lookup at 9.7.
Being the only candidate is not evidence. It means the channel was quiet,
which is exactly when guessing is weakest -- the single-candidate rule was
meant to avoid picking wrongly among several, not to license a match no other
signal supports.
Every correct thin attach in that dump was <= 3.4 minutes idle and every wrong
one was >= 8.2, so 5 separates them with room on both sides. Real
back-and-forth is unaffected: it runs through the 30-second tier-1 path, and
the observed conversational replies sit near zero. Tests pin both sides of the
new boundary at 4.9 and 5.1 minutes so a later change to this number has to be
deliberate. 23 pass.
Also corrects a DEFERRED.md entry written earlier today. It claimed nothing
ever closes an incident that goes quiet; summarizer.py has run a stale sweep
at incident_auto_resolve_minutes (90) the whole time. The 37 open incidents
were caused by over-creation, not by a missing sweeper, and 90 minutes may be
fine now -- worth rechecking on a fully post-fix dump before changing it.
No new environment variables: tg_dispatch_thin_idle_minutes is a config.py
default and is not templated into any .env, so CI deploys this without an
ansible run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
httpx logs every request at INFO as a full URL including the query string, so
the Google Maps key appeared in c2-core's container logs on every geocode call
-- `?address=Holland+Station&...&key=AIza...`. Anyone who can read the logs, or
who is pasted a few lines of them, has the key. It was found exactly that way
while checking why the map was empty.
Nothing in this service needs per-request client logging; callers already log
their own failures with context. httpx and httpcore drop to WARNING, so real
transport errors still surface and the URLs stop being printed.
This does not un-leak the existing key -- it is in the container's log history
and has to be rotated in GCP separately.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The 23:46Z correlation dump confirmed the severity gate fixed the problem it
was written for -- orphans fell from 69 to 16, and only three of those are
after the deploy boundary, two of them deliberate skips. Nothing on TG 9048
absorbs the channel any more; the largest post-deploy incident is four calls
over nine minutes and is genuinely one event.
It overcorrected. 37 of 50 incidents were open, most a single routine call.
The cause was the gate's own substance test, which counted `units` and
`location`. Radio protocol puts a unit ID in essentially every transmission
and a place name in most of them, so has_substance was true almost always and
the severity check never actually ran -- "11-Victor, 72 at Holland Station"
became its own permanent incident. Substance is now a vehicle, a geocode or a
tag: things the extractor found beyond who was speaking and where they stood.
Severity still opens an incident on its own, so nothing real is lost.
incident_type is now validated against the enum the prompt offers rather than
trusted. It is written straight through to incident.type and rendered as the
title, so a model that answered the severity question in the type field
produced an incident titled "Routine -- TGID 9563". Unrecognised values become
None and fall to the tag/severity path, which is what "unknown" already did.
The map was empty for a separate reason: geocoding accepted only ROOFTOP and
RANGE_INTERPOLATED. Dispatch names places the way people speak, and Google
returns GEOMETRIC_CENTER for exactly those forms -- intersections ("Lake
Street and Veterans Memorial Drive") and named POIs ("Brewster Station").
Requiring a street address discarded nearly every real dispatch location and
left only numbered addresses plotted, which is why the July incidents have
coordinates and none since do. GEOMETRIC_CENTER is now accepted; APPROXIMATE
is still rejected, since a region centroid is what an ungeocodable string
degrades to. Note this is necessary but may not be sufficient -- if
GOOGLE_MAPS_API_KEY is unset on the host the map stays empty regardless, and
that has not been checked from here.
Two things found and deliberately not fixed, both in DEFERRED.md. One call can
still land in two incidents, because upload.py correlates each extracted scene
independently and the model over-split one conversation; multi-scene is
intentional, so that is prompt tuning rather than a code change. And nothing
closes an incident that merely goes quiet -- signal-resolution and master
auto-resolve both exist, but a one-call incident nobody clears stays active
forever. That wanted the over-creation fixed first so a time-based sweeper
would not just paper over it.
Gate tests updated: units and location alone must now orphan, and the case
that matters most is kept explicit -- units with a real severity still open an
incident. 17 pass. No new environment variables, so CI deploys this without an
ansible run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The UI worked but read as an operator console: no public face, no way to
describe or sell the thing, and no account surface beyond the node list. This
adds the missing halves and reorganises what was already there around the
incident, which is the unit of value the rest of the pipeline is built to
produce.
A shared design system replaces per-page styling: components/ui (Button, Card,
Badge, EmptyState, Skeleton, PageHeader), a type scale and shadow set in the
Tailwind config, and light-mode tokens in globals.css. The existing
html:not(.dark) remap mechanism is extended rather than replaced -- a parallel
theming system would have been two sources of truth for the same colours.
Public marketing pages (/, /features, /pricing, /faq) load without a session.
middleware.ts gained a PUBLIC_PATHS allowlist to permit that; it remains a UX
redirect and is still NOT an authorisation boundary, which the comment there
says explicitly. Real enforcement is unchanged and still lives server-side in
c2-core's auth.py. Chrome switching is done by pathname in ChromeSwitcher
instead of by route group, because a route group would have collided on / and
forced most of app/ to move for no behavioural gain.
Billing and API keys ship as typed stubs, not integrations. lib/billing.ts and
lib/apiKeys.ts define the data model and the screens consume it, but every
mutating call throws with a message naming the backend route that has to exist
first, and the sample data is labelled as sample. Nothing here can charge
anyone or mint a real credential -- picking a payment processor and holding its
keys is a decision for a human, and a half-wired checkout is worse than an
obviously absent one.
The severity work from the c2-core change lands here too. severity is now a
filter and sort dimension on the incident list rather than decoration, since
a busy dispatch channel is only readable if you can collapse it to moderate and
above. routine gets a muted treatment because it is the majority of traffic,
legacy "unknown" still renders nothing, and TypeBadge handles the new "other"
incident type. Severity rendering moved into lib/severity.tsx so the incident
list, incident detail and call rows cannot drift apart.
Deliberately not touched: calls, map, alerts, nodes, systems, tokens, trips and
admin. They already share the palette and stay coherent, and rewriting them
would have buried the parts that actually needed to change. No colour tokens
were renamed, so nothing regressed there.
Verified with tsc --noEmit (npm run typecheck), clean. No runtime verification
was possible and none was done. No new environment variables.
The 2026-08-16 correlation dump showed two failures that looked unrelated and
were the same bug. TG 9048 held one incident of 28 calls spanning 49 minutes --
a prisoner transport, a drone retrieval, a records lookup and a canvass, glued
together -- while 32 other calls on that same channel stayed permanently
orphaned.
Creating an incident required a concrete incident_type. Nothing on a transit
police channel produced one: the extraction prompt said to prefer "other" when
uncertain, extraction then collapsed "other" to None, and the tag-based fallback
had no tags to work with because administrative traffic carries none. So the
channel could never open a SECOND incident. Every later call funnelled into
whichever incident happened to exist first, and every call too substantial for
the thin path had nowhere to go at all. The two symptoms were the same missing
value seen from opposite ends.
Severity now decides incident-worthiness. It is a better fit for the question
being asked -- "is this a real event?" -- than a service label ever was, and
unlike incident_type it is always present. The prompt defines four levels with
no escape hatch (routine/minor/moderate/major, "unknown" is gone) and calls
skipped for a too-short transcript are still recorded as routine, because
downstream code reads a missing severity as "not processed yet" rather than
"nothing happened". Anything above routine, or carrying any extracted content,
opens an incident under the neutral "other" type. "other" is also kept as a real
classification now -- rail operations and public works genuinely are not police,
fire or EMS.
Separately, thin calls no longer refresh updated_at; they write last_thin_at.
updated_at drives every recency gate in the fast path, so each "10-4" was
resetting the idle clock on whatever it attached to, keeping that incident
inside the gate for as long as anyone kept acknowledging. An incident now ages
from its last substantive call. This is what made the 49-minute incident
possible even once buckets existed, so it is fixed independently rather than
being left to the gate change.
The re-correlation sweep also now honours skip_reason. /upload has always
refused to correlate garbage and too-short transcripts, but the sweep did not
apply the same filter, so those fragments came back minutes later through the
thin path and attached to whatever was most recent -- a second, quieter route
into the same over-merge.
Adds tests/test_correlator_gate.py (15 cases), the first tests against
incident_correlator.py in its 1,517-line history. tests/conftest.py stubs
firebase-admin only when it is genuinely absent, so the container's real SDK is
never shadowed; this is what makes the correlator importable in the dev venv.
That stub also made test_mqtt_handler and test_node_sweeper collectable for the
first time, revealing 10 pre-existing failures in them -- test-vs-code drift,
untouched here and catalogued in DEFERRED.md.
No new environment variables, so CI deploys this without an ansible run.
Two independent sources of garbage in the AI pipeline, both visible in the
2026-08-16 correlation dump.
1. Hallucinated transcripts. The Whisper prompt opened with an enumerated run
of ten-codes: 10-4, 10-23, 10-20, 10-97 and so on. Whisper treats prompt
text as preceding transcript, so on noisy or silent audio it continued the
series, emitting transcripts that count upward from 10-4 to 10-99. The
existing no_speech_prob filter could not catch these: the model is highly
confident in text it invented by continuing a pattern.
The prompt no longer contains a series to extend, and _is_degenerate()
rejects the three shapes this failure takes: ascending ten-code runs, one
phrase looping, and near-identical segments across a whole recording.
Verified against 13 transcripts from production: all four known
hallucinations rejected, all nine real ones kept, including terse traffic
containing legitimate codes.
2. Duplicate recordings. node-002 and node-PI-2 both cover TG 9048 and both
uploaded the same transmissions, ~1.1s apart. Nine pairs appeared in one
dump. Each was transcribed, billed and correlated twice, and the resulting
incident listed two units where there was one.
Canonical selection is by earliest started_at, tie-broken on call_id, NOT
by upload order: upload order varies with encode time and network latency,
so it would make the authoritative recording non-deterministic. Call
documents are created from MQTT call_start before uploads arrive, so both
nodes independently reach the same verdict. The loser keeps its audio (it
may be the cleaner capture) but is excluded from STT, correlation, the
re-correlation sweep and the orphan debug view.
Also fixes _sync_transcribe returning a bare None when OPENAI_API_KEY is
missing, where the caller unpacks two values. A missing key surfaced as a
misleading "Transcription failed" instead of the real warning.
Adds tests/test_dedup.py (15 cases). dedup.py reaches Firestore through an
injected callable so it stays importable without firebase-admin present.
upload_audio() could only sign a URL when GCP_CREDENTIALS_PATH pointed at a
service-account key file. The deployed VM runs on Application Default
Credentials with no key file, so every upload silently took the fallback
branch and returned a bare gs:// URI. That broke two things at once:
* Browsers cannot fetch a gs:// URI, so no recording was ever playable.
* _public_url_to_gcs_uri() only matched https://storage.googleapis.com/ and
returned None for it, so `if gcs_uri:` in the upload path was always false
and transcription never ran. Nothing was logged, which is why this looked
like an OpenAI credits problem rather than a storage one.
The fallback also interpolated the client-supplied filename instead of the
call_id-derived safe name, so the URI did not even name the object written.
Calls now store only the canonical gs:// location. A short-lived playback link
is minted per read as an HMAC over (call_id, expiry) keyed by SERVICE_KEY, and
audio is served from the private bucket by the new /media route. An <audio src>
cannot carry an Authorization header, so the link has to be the credential;
that router is therefore public with the check done inline, as enrollment.py
already does. Signing GCS URLs from the VM would have needed a
serviceAccountTokenCreator grant on its own service account — this avoids the
IAM change entirely and keeps the bucket private.
gcs_uri_for_call() reconstructs the object name from call_id, so recordings
made before this fix are reachable again without a data migration.
Frontend rows come straight from Firestore via onSnapshot and never see a
server-minted field, so CallRow fetches the link lazily on expand.
Also removes the last long-lived (1 year) signed URL and the log line that
printed it.
The node builds its OP25 config from GET /systems, but that router only
accepted a Firebase token or the shared service key — a node holds neither.
Every fetch returned 401 and the node fell back to its stale offline cache,
so a system edited in the UI never reached the field. Confirmed on node-002
against the live server: "Failed to fetch systems from C2: 401 Unauthorized
... Offline cache will be used."
The node sends no node_id with the request, only the bearer token, so the
key is matched by querying node_keys for the value instead of fetching a
known document the way /upload does.
Read access only: the mutating routes in this router each carry their own
require_admin_token, so widening the router-level gate doesn't let a node
create, edit or delete a system.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The broker crash-looped on every deploy: "Unable to load server certificate
/mosquitto/certs/mqtt.crt ... Permission denied". The cert-sync script wrote
600 root:root into a 0700 root:root directory, on the assumption that
mosquitto runs as root inside its container. It does not — the stock
eclipse-mosquitto entrypoint drops privileges to the in-image mosquitto
user, confirmed on the server as uid=1883(mosquitto) gid=1883(mosquitto),
and the broker's own log says so on every start.
Certs dir is now root:1883 0750 with the cert 0644 and the key 0640, and
the data dir is 1883:1883 recursively — recursively because mosquitto
WRITES dynamic-security.json there, and a root-owned file left by an
earlier deploy would still be unwritable after a directory-only chown.
Also drops the "unverified Caddy cert path" note: a real issuance confirmed
the path, producing CN=mqtt.drb.cusano.net signed by Let's Encrypt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Shallow clones were never a Gitea packing bug. An intruder had set
uploadpack.packObjectsHook in Gitea's HOME gitconfig, pointing at a
non-executable dropper, so every upload-pack died mid-pack. That hook is
gone and --depth=1 clones are verified working, so fetch-depth: 0 buys
nothing but slower CI. See INCIDENT-2026-08-11.md.
infra/Caddyfile was dead: ansible templates Caddyfile.j2 to
/etc/caddy/Caddyfile, and nothing ever deployed the static copy.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
actions/checkout defaults to depth=1, and Gitea aborted generating that pack
with a bad pack header protocol error on all three retries, failing the build
before any image was pushed. A full fetch avoids the shallow-pack path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
app_url advertised https://app.<domain>, which has never had a DNS record —
the frontend is served on the bare domain by Caddy. Adds mqtt_host so the
broker endpoint nodes connect to is discoverable from terraform output.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Edge nodes are deployed to arbitrary locations by arbitrary people, so the
broker has to be reachable from the internet and secured on its own merits
rather than by a VPN.
Three defects made that impossible. The broker only had a plaintext 1883
listener; every node shared one drb-node password; and the ACL pattern used
%c, the client-supplied client id, so any holder of that shared password
could set client_id to another node and take over its namespace. The comment
claiming this cryptographically prevented cross-node access was wrong and is
gone.
Authentication now uses mosquitto 2.x's built-in dynamic-security plugin on
the stock eclipse-mosquitto image. c2-core administers it over the control
topic, creating each node's client on approval with username=<node_id> and
password=<its node_keys api_key>, attached to a role whose ACL is nodes/%u/#
against the authenticated username. One credential, one revocation point.
An HTTP-callback plugin was implemented first and rejected: that project is
archived upstream, which is not an acceptable dependency on an
internet-facing broker.
Because dynsec state is a second source of truth alongside Firestore,
approve/reissue/delete now write to the broker first and surface a 502
rather than drifting, and c2-core reconciles every approved node into dynsec
on startup.
Adds node self-enrollment (POST /nodes/enroll, GET /nodes/{id}/credentials)
so a new node can obtain its key over HTTPS without an operator handling
secrets by hand. Enrolling an already-approved node_id is refused on the
fleet token alone — otherwise a leaked token plus a guessable id would let
an attacker steal a live node's key before the real node asked for it.
Pickup secrets are stored hashed and returned once, and the endpoint is rate
limited per source IP.
Infrastructure: an 8883 TLS listener fed by Caddy's certificate via a
systemd path unit, a firewall rule for it, and Caddy now 404s /internal/*
so the api vhost cannot proxy internal routes.
Also fixes CORS, which allowed https://app.<domain> while the frontend is
served on the bare domain — every call from the portal would have failed —
and widens the vault gitignore to a glob, since ansible-vault leaves
backup siblings that the exact-name rule left committable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Only drb.cusano.net and api.drb.cusano.net have public A records, so the
app.<domain> vhost had no cert to present and the bare domain — the record
that actually exists — matched no site at all, producing
ERR_SSL_PROTOCOL_ERROR in the browser.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A password containing "$fP" was interpolated away by compose, giving
mosquitto and c2-core two different passwords and producing
"MQTT connect refused: Not authorized" with nothing in the logs pointing at
the cause. Recorded next to the values so the next person generating
credentials sees it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Compose interpolates the top-level .env, so a password containing "$fP" was
read as the variable $fP and replaced with an empty string — hence the
repeated "The \"fP\" variable is not set" warnings on every compose command.
The env_file templates are not interpolated, so c2-core kept the literal
password while mosquitto's entrypoint received the mangled one. The two sides
disagreed and c2-core could not authenticate to the broker. Escaping $ as $$
here (and only here) makes compose collapse it back to the real value.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docker-compose.prod.yml: compose merges `ports` by appending, so the prod
override left the base file's 8888:8000 and 3000:3000 in place next to the
127.0.0.1-scoped ones. Each container tried to bind its port twice and the
second bind failed with "address already in use", so c2-core and frontend
could never start. It also meant the localhost-only binding never applied —
both ports were published on every interface. Marked both `!override`, the
same way mosquitto already used `!reset`.
infra/ansible:
- add the missing "Reload Caddy" handler; the Deploy Caddyfile task notified
a handler that did not exist, which aborts the play
- guard mkswap/swapon on whether /swapfile is already active, so a second run
does not fail on "mounted" / "Device or resource busy"
- git task now updates instead of clone-once, otherwise a re-run redeploys
whatever code was on the VM at first clone
- vault.yml.example: correct the registry token comment to read-only scope
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Terraform: e2-micro VM (us-east1-b, free tier), static IP, SSH/web
firewall rules, IAM bindings for Firestore + GCS; imports existing
drb-calls bucket and c2-server Firestore database into state
- Gitea CI: build c2-core, discord-bot, frontend images and push to
git.vpn.cusano.net registry; SSH deploy pulls pre-built images (no
build on VM)
- Ansible: first-time setup only — git clone, env files from vault,
Caddyfile, docker login + compose pull + up; no rsync or on-VM builds
- docker-compose: add image: ${REGISTRY}/name:latest alongside build:
so local dev and CI registry both work
- gitignore: add Terraform state, lock, tfvars, ansible secrets
Provisions e2-micro VM (us-east1-b, free tier) with static IP, SSH and
web firewall rules, Docker + Caddy startup script, and IAM bindings for
Firestore and GCS access via ADC. Imports existing drb-calls bucket and
c2-server Firestore database into state. Ansible roles handle first-time
setup (swap, docker group) and all subsequent deploys via rsync + docker
compose, with secrets managed via Ansible Vault. DNS stays on AWS Route 53.
Avatar (initials) + display name, email, admin badge
Account section: email, UID, role, join date, last sign-in
Discord section: link status with username/user ID/linked date, or the get-code flow if unlinked, plus unlink button
Sign out button at the bottom
Trip-level tags: admins configure available tags in the trip header (inline add/remove pills). The AI can also create new tags via the add_tag tool.
Event tags: selectable in the Add Event modal, shown as colored pills on event cards in the timeline, and on AI suggestion cards.
AI integration: sees available tags in its system prompt, applies them when proposing events, can create new ones with add_tag.
Discord: tags shown as inline code blocks under each event in /trip view.
Colors: auto-assigned from an 8-color palette by tag index, consistent everywhere.
Focus: textarea gets refocused via inputRef after the AI response (or error) lands
Persistence: chat history saved to localStorage keyed by trip ID, loaded on mount — survives refreshes
Switch from legacy Places textsearch and Directions APIs (disabled on
this project) to Places API (New) and Routes API (New). Both places.py
and the assistant's _places_search helper updated. Also fixes uid()
recursive self-call in trips page and adds Places API response logging.
auth.py
secrets.compare_digest replaces == for service key comparison (timing-safe)
Added require_service_key — bot-only endpoints (trip/event join/leave)
Added require_service_key_or_admin — node commands/config (bot via service key OR dashboard admin via Firebase)
Added _RateLimiter with three shared instances: trip_chat_limiter (20/5min per user), summarize_limiter (5/10min per incident), bootstrap_limiter (2/hr per system)
nodes.py
send_command and assign_system now require require_service_key_or_admin — the Discord bot can still call them via service key, but regular Firebase users are blocked
tokens.py
add_token, flush_tokens, set_preferred_system, delete_token all require require_admin_token
Token masking changed from token[:10] + "…" + token[-4:] to "•••" + token[-4:]
systems.py
All write endpoints (create, update, delete, ai-flags, ten-codes, vocabulary writes, bootstrap) now require require_admin_token
bootstrap_vocabulary also calls bootstrap_limiter.check(system_id)
incidents.py
POST /incidents/summarize (bulk) now requires require_admin_token
POST /incidents/{id}/summarize now calls summarize_limiter.check(incident_id)
trips.py
join_trip, leave_trip, join_event, leave_event require require_service_key — only the Discord bot can set Discord attendee identity
delete_trip, delete_event require require_service_key_or_admin
trip_chat rate-limited per caller UID, history stripped to user/assistant roles only, user message truncated to 2000 chars, Maps query strings capped at 200 chars
upload.py
Rejects files larger than settings.upload_max_bytes (default 100MB) with 413
storage.py
_safe_audio_filename() derives GCS object name from call_id + allowlisted extension, completely ignoring the client-supplied filename
config.py
Added upload_max_bytes: int = 100 * 1024 * 1024
Both Dockerfiles — python:3.14-slim → python:3.12-slim
lib/types.ts — TripRecord and TripEvent types
lib/c2api.ts — getTrips, getTrip, createTrip, deleteTrip, createTripEvent, deleteTripEvent
lib/useTrips.ts — Firestore realtime hook on the trips collection, ordered by start date
app/trips/page.tsx — List page split into Upcoming / Past sections, card click navigates to detail, "+ New Trip" modal for admins with all fields including date range and maps link
app/trips/[id]/page.tsx — Detail page fetched via C2 API (gets trip + events in one call), day-by-day itinerary with time, location, maps link, notes, and Discord attendees. Add Event modal (date constrained to trip range). Admin-only delete trip + remove event.
components/Nav.tsx — Trips link added to the nav
New /trips router with full CRUD, attendee management, and nested
events. Events validate date is within parent trip range and inherit
trip location when not explicitly set. Leaving a trip cascades
removal from all its events.
New TripCommands cog with /trip create, list, view, delete, join,
leave and /trip event add, remove, join, leave. Event autocomplete
is scoped to the selected trip. Enforces must-be-on-trip rule for
event joins with a clear error message.
- Create path (line ~1274): title only uses "at {location}" when location_coords is also set
- Update path (line ~1226): same guard — best_coords must be truthy alongside best_location
Frontend (MapView.tsx):
- Desktop sidebar: cards with location_coords → <button> fly-to; cards without → <a href> that navigates to the incident page with "View details →" text
- Mobile drawer: same split — with coords fly-to+close, without coords navigate via <a>
- Removed the "no coords" italic placeholder text; the card behavior itself makes it clear
app/map/page.tsx
Removed IncidentCard component and the incidents grid below the map — the on-map sidebar inside MapView is the single display
Moved kiosk exit button from top-3 left-3 (overlapping zoom controls) to bottom-[5.5rem] left-3
components/MapView.tsx
Fixed popup "View incident →" link — adds stopPropagation() + window.location.href to prevent Leaflet intercepting the click
Added "View details →" link on each sidebar incident card so you can navigate from the map panel without opening a popup
Added "News Alerts" overlay layer (placeholder, ready for RSS/feed integration)
lib/types.ts
Added preferred_token_id?: string | null to SystemRecord
lib/c2api.ts
Added setPreferredToken(tokenId, systemId) calling PUT /tokens/{tokenId}/prefer/{systemId} (backend already existed)
app/systems/page.tsx
Added PreferredTokenPanel component — loads the token pool lazily on expand, shows radio buttons to set/clear the preferred token, displayed on each system card above the AI flags panel
- correlator: unit_overlap on dispatch channels now applies content
divergence check when the call has geocoded coords but the incident
doesn't; previously this gap caused unrelated calls to merge into
stale incidents (e.g. patrol officer at a second scene 70 min later)
- STT: switch default model from gpt-4o-transcribe to whisper-1, which
faithfully transcribes all exchanges in multi-PTT recordings; gpt-4o
was silently dropping utterances, starving the correlation engine
- STT: remove vocabulary from the Whisper prompt; whisper-1 echoes
prompted terms into noise/silence, skewing extracted incident data;
vocabulary context is now applied exclusively in the GPT extraction
step (build_gpt_vocab_block) where it is used as reference only
When the induction loop proposes a new vocabulary term, it now records
which sampled call(s) most likely produced the suggestion. Admins see
a collapsible "▶ source" player under each pending term showing the
audio clip and transcript, so they can hear what was actually said
before approving or dismissing.
- vocabulary_learner: track sampled call docs, attach source_call_ids
to each pending term via word-overlap search with fallback
- types: VocabularyPendingTerm.source_call_ids?: string[]
- c2api: add getCall(id) using existing GET /calls/{call_id} endpoint
- VocabularyPanel: SourceCallPlayer component — lazy-loads call on
first expand, shows audio controls + transcript snippet
The loop slept 24h before its first pass, so suggestions would never
appear unless the server was up for a full day. Move the sleep to the
end so the first induction pass runs ~30s after startup.
Add a SPEAKER ROLES section to the GPT-4o-mini prompt teaching it to
distinguish dispatch voice (names a unit then gives assignment + address)
from unit voice (opens with own callsign + brief status). Applied to
location attribution (dispatch-provided address beats unit position report)
and unit extraction (dispatched units vs. acknowledging units). No extra
API calls — purely prompt-level reasoning on the existing transcript.
_handle_status was calling doc_update unconditionally, which throws a 404
when a node has been deleted from the UI but is still running and sending
heartbeats. Catch the "No document to update" error and log at info level
instead of bubbling up to the dispatch error handler.
Refactor incident_correlator.py to a decision/commit split (preview_correlation
/ apply_correlation) so the rules engine and LLM can both produce decisions before
anything is written to Firestore.
Add llm_correlator.py: cheap Gemini Flash first-pass + Gemini Pro tiebreaker.
Wire _correlate_with_consensus in upload.py — rules-only fallback when key is
absent or call is thin; agreed/tiebreak consensus written to corr_debug.
- Remove time_fallback from _call_fits_incident: a substantive call with no
matching signals (unit/vehicle/location) is now always orphaned on dispatch
channels rather than attached by recency alone
- Pursuit-mode location: incidents tagged as vehicle-pursuit/pursuit/chase use
a 20km expanded radius with speed-sanity validation (distance ÷ elapsed time
must be ≤ 8 km/min) — location change is a positive signal for moving incidents
- Non-pursuit incidents: strict 0.5km proximity unchanged — location change = reject
- Thin path two-tier: ≤30s → attach to most-recent regardless of candidate count
(direct conversational reply); 30s–10min → single candidate required
- Geocoding: reject GEOMETRIC_CENTER/APPROXIMATE results — vague location strings
(regions, city centroids) were resolving to node-area coords and creating false
proximity matches that merged unrelated incidents
- Thin path: on dispatch channels with multiple active incidents, skip attachment
rather than guessing — "10-4" with 3 active incidents is genuinely ambiguous
- Short transcripts (≤5 words) now write skip_reason="transcript_too_short" to
the call doc, matching garbage transcript behavior
- upload.py no-scenes fallback now checks skip_reason before running correlation —
flagged calls (garbage, too short) no longer attach via thin path
- Update Server README to reflect current project purpose, goals, and pipeline
- Cap unit-continuity path at 20 min idle (unit_continuity_max_idle_minutes)
- Block time_fallback and unit-continuity matching on reassignment calls
- Expand reassignment detection to cover unit-initiated self-reassignment
- Skip GPT extraction entirely for transcripts ≤5 words (prevents hallucinated tags/units)
- Reduce geocode_max_km from 75 to 40 to reject far-out-of-area results
- Include county in geocoding query for tighter jurisdiction anchoring
geocode_max_km: 25 → 75 km. The node is a physical receiver, not the system boundary;
digital repeaters extend coverage well beyond 25km (North White Plains at 35.5km from
the Yorktown node is a legitimate Westchester County location).
Query now fully qualified: "High Street" → "High Street, Yorktown, New York".
Added _get_node_state() which reverse-geocodes the node position once (cached) using
Google Maps to get the state name, appended alongside the municipality.
Generic street names (High Street, Main Street) no longer resolve to wrong-country results.
The package consistently throws 'L.GridLayer.GoogleMutant is not a constructor'
due to L-instance conflicts in the webpack bundle, despite multiple workaround
attempts. Removed package, transpilePackages entry, type stub, env var, and all
related component code. Traffic overlay dropped; geocoding (backend) unaffected.
Fit and traffic buttons were hidden behind the legend at bottom-right.
Moved both into a column group at top-left below the zoom controls,
where there is clear unobstructed space. Replaced emoji with TRF text label.
Replace static import + createLayerComponent approach with dynamic import()
inside a useEffect, which ensures leaflet.gridlayer.googlemutant augments the
same L instance that's active at runtime. Add loading=async to Maps JS script.
Traffic is now toggled via a dedicated button (green when active) rather than
LayersControl, bypassing the react-leaflet layer lifecycle that caused the
constructor conflict.
Add leaflet-google-mutant@0.16.0 (exact/locked version) as a proper bridge
between the Google Maps JavaScript API and Leaflet. The old mt{s}.google.com
tile URL approach was unofficial and produced empty tiles.
Traffic layer now renders via createLayerComponent + googleMutant, loaded only
after the Maps JS API script is injected and ready (keyed off NEXT_PUBLIC_GOOGLE_MAPS_API_KEY).
Added leaflet-google-mutant to transpilePackages in next.config.mjs.
Switch geocoding from Nominatim to Google Maps Geocoding API for accurate
local place name resolution (bounds-biased, with 25km distance rejection guard).
Remove the now-unused _get_node_place reverse-geocoder and _node_place_cache.
Map page (TOC improvements):
- Weather radar tiles auto-refresh every 5 minutes via radarEpoch key cycling
- Google Maps traffic overlay added to LayersControl
- Live 24h clock overlay at bottom-left for situational awareness
- Incident sidebar cards now show age (time since dispatch) and unit count
Nominatim's viewbox is advisory (bounded=0), so ambiguous place names like
"Pinebrook" can resolve to locations 30-40km away in the wrong town. Added
a post-geocode distance gate: results farther than geocode_max_km (default
25km) from the node are discarded with a warning log rather than written to
the incident.
Also logs distance on successful geocodes for easier audit.
New config setting: geocode_max_km (float, default 25.0)
_call_fits_incident now returns (bool, signal_str) so each correlation
decision records exactly what evidence fired: unit_overlap, vehicle_overlap,
location_proximity, time_fallback, tactical_default, or the corresponding
false-return variants (unit_loc_conflict, content_divergence, etc.).
- corr_fit_signal and corr_matched_units written to call docs for
fast/single and fast/disambig paths
- Admin debug endpoint exposes the new fields in calls_detail
- Orphan section adds orphans_by_talkgroup summary (count, no-type count,
sweep-exhausted count per TGID) and raises orphan limit 100 → 250
- Admin page shows corr_path and fit_signal distribution panels above raw
JSON; time_fallback highlighted in yellow as a diagnostic marker
No correlation logic changed — diagnostic data only.
Phonetic run threshold 5 → 12: a plate spellout ("Foxtrot Alpha Uniform Lima
Kilo...") produces 6–8 consecutive phonetic words, triggering false positives
and blocking intelligence extraction on legitimate calls. 12 is safely above
any real spellout (~8 max) while still catching the full-alphabet hallucination
(26 words). Also writes skip_reason="garbage_transcript" to the call doc and
surfaces it in the admin correlation debug endpoint.
- intelligence.py: detect Whisper phonetic-alphabet hallucinations before
sending to GPT; skip extraction entirely to prevent fake units/tags
corrupting correlation
- intelligence.py: upgrade node reverse-geocode from zoom=5 (state) to
zoom=10 (county) and include county in address queries so common street
names (e.g. "East Main Street") resolve to the correct county
- incident_correlator.py: add "patched" and "primary" to dispatch channel
regex so patched trunking channels are treated as shared backbones
- incident_correlator.py: add 20-min idle gate for tactical channel default
so a reused frequency can't absorb a new unrelated incident