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