Compare commits

...
Author SHA1 Message Date
Logan CusanoandClaude Sonnet 5 d67b2057e6 frontend: safe fixes from the #109 punch-list
- CallSpineEntry.tsx: drop the dead `hasAudio` prop + the early `return null`
  that sat between hooks in InlinePlayer (React #310 risk). Parent already
  gates the mount on audio presence.
- NodeCard.tsx + nodes/page.tsx: pending-node card no longer double-fires.
  NodeCard gains `linkToDetail` (default true); the pending branch passes
  false so the wrapping onClick (open config modal) isn't swallowed by the
  inner <Link> navigation. List view unchanged.
- trips/page.tsx: TripCard badge now buckets on end_date >= today, matching
  the list's own upcoming/past split — an in-progress trip no longer shows a
  "Past" badge under "Upcoming".
- trips/page.tsx, NodeConfigModal.tsx, nodes/[id]/page.tsx: tall modals get
  `p-4` on the overlay + `max-h-[90vh] overflow-y-auto` on the panel so they
  don't clip on short viewports (incidents' CreateModal pattern).
- lib/types.ts: IncidentRecord.units / vehicles are optional now, matching
  Firestore (older docs omit them); incidents/[id] gains a `?? []` guard.

Untypechecked (no node/npm locally). next build in deploy.yml gates it.
Full list of remaining items in server-26 #109.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 00:07:54 -04:00
logan c1c3e89e1d frontend: fix map stacking + honest infra error states (#108)
Build & Deploy / Build & push images (push) Successful in 4m3s
Build & Deploy / Deploy to VM (push) Successful in 2m3s
Build & Deploy / Report a failed deploy (push) Skipped
2026-09-06 23:49:19 -04:00
Logan CusanoandClaude Sonnet 5 968134f8ee frontend: fix map stacking + honest infra error states
From a live review of drb.cusano.net.

MapView.tsx / globals.css:
- The Leaflet map painted above the sticky Nav (z-40) and modal overlays, so
  on Live the account dropdown opened *behind* the map. Pin .leaflet-container
  to its own stacking context (position:relative; z-index:0) — keeps Leaflet's
  internal pane order, drops the whole map below app chrome. The map's own
  overlay UI (legend, rail, clock, fit-all) is outside .leaflet-container and
  unaffected. Chosen over raising Nav's z-index, which would float the sticky
  header over modal backdrops on ~7 pages.
- Basemap: the "Dark" tile URL is already CARTO's keyless dark raster (so a
  prod "API KEY REQUIRED" watermark is a stale build or CARTO rate-limiting
  the origin, not this code). Add NEXT_PUBLIC_MAP_TILE_URL as a build-time
  override so a keyed style drops in without a code change; add the OSM
  attribution the keyless CARTO tiles require.

incidents/page.tsx, alerts/page.tsx:
- Both dumped raw Firestore "requires an index / PERMISSION_DENIED" strings
  (with a console.firebase URL) straight into the UI when the composite
  indexes aren't deployed (server-26 #13/#51). Collapse those known infra
  failures to a plain sentence; any other error passes through verbatim so a
  real bug still shows. alerts also now surfaces the events-query error at
  all — it was swallowed, showing a false "No alerts triggered yet." on a
  public-safety screen.

onboarding/page.tsx: stale comment (/dashboard -> "/").

Untypechecked (no node/npm locally); presentational only — one string
helper, one added error branch, a CSS rule, two tile-URL constants, a
comment. next build in deploy.yml gates it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 23:43:22 -04:00
logan b430cf32f2 Merge pull request 'frontend: install command uses the node id from the mint form (node-26#4)' (#107) from feat/mint-panel-nodeid into main
Build & Deploy / Build & push images (push) Successful in 5m51s
Build & Deploy / Deploy to VM (push) Successful in 1m42s
Build & Deploy / Report a failed deploy (push) Skipped
Reviewed-on: #107
2026-09-06 20:15:10 -04:00
Logan CusanoandClaude Sonnet 5 93fa3a6054 frontend: install command uses the node id from the mint form (node-26#4)
The mint panel's copy command hard-coded --node-id node-XXX. Now the label
just entered (the operator types the node id there — placeholder relabeled
"Node ID, e.g. node-003") is captured on mint and interpolated into the
command: spaces → dashes, non [A-Za-z0-9_-] stripped (install.sh's rule),
falling back to node-XXX only if that yields nothing. The "edit node-XXX"
hint now only shows in the fallback case.

Not typechecked (no node/npm here); one useState<string|null>, one derived
string, a JSX conditional. `next build` in deploy.yml gates it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 20:13:23 -04:00
logan de03f5bcaf Merge pull request 'frontend: mint panel shows the full one-shot install command (node-26#4)' (#106) from feat/mint-panel-install-command into main
Build & Deploy / Build & push images (push) Successful in 5m6s
Build & Deploy / Deploy to VM (push) Successful in 1m40s
Build & Deploy / Report a failed deploy (push) Skipped
Reviewed-on: #106
2026-09-06 19:31:34 -04:00
Logan CusanoandClaude Sonnet 5 0651bfe07a frontend: mint panel shows the full one-shot install command (node-26#4)
After a node enrollment token is minted, the panel now renders the
paste-ready `curl -fsSL .../install.sh | sudo bash -s -- --token <minted>
--node-id node-XXX --c2-url <derived> --mqtt-broker <derived>` line with a
Copy button, alongside the bare token (also kept, also now copyable).

- c2-url from NEXT_PUBLIC_C2_URL (same var lib/c2api.ts reads), fallback
  https://api.example.net
- mqtt-broker derived as mqtt.<api-host minus leading api.> — a DNS
  assumption; the panel text tells the operator to check it
- node id is a node-XXX placeholder; the panel collects none

Pairs with node-26's install.sh (feat/one-shot-install). The raw/tag/v1/
URL resolves once v1 is re-cut at that PR's merge.

NOT typechecked here (no node/npm in this environment); plain React, two
useState booleans + one computed string, reviewed by eye. `next build` in
the deploy workflow will catch a real type error before it ships.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 19:15:55 -04:00
logan c4656a9607 Merge pull request 'correlator: judge each scene on its own embedding + severity (#80, #95)' (#105) from fix/scene-context-leak-80-95 into main
Build & Deploy / Build & push images (push) Successful in 5m1s
Build & Deploy / Deploy to VM (push) Successful in 2m8s
Build & Deploy / Report a failed deploy (push) Skipped
Reviewed-on: #105
2026-09-06 17:49:23 -04:00
Logan CusanoandClaude Sonnet 5 a9d1d2475a correlator: judge each scene on its own embedding + severity (server-26#80, #95)
intelligence.py writes only the primary scene's embedding and severity to
calls/{id}. _build_context read them back off the call doc, so every
non-primary scene of a multi-scene call was correlated against scene 1's
semantic vector and severity rung: a scene about a different event scored
on the embedding path against the wrong incident, and could inherit a
minor/moderate/major severity it never had, clearing the creation gate on
borrowed weight. Same defect and same fix as the #87 coords leak.

- _build_context / preview_correlation / correlate_call: take embedding and
  severity as params; drop the call_doc.get() fallbacks. A scene that
  passes none has none, and is judged thin on its own signal.
- upload.py: both scene loops pass scene["embedding"] / scene["severity"];
  _correlate_with_consensus forwards them. The no-scene unclassified branch
  passes neither (correct: no scene, judged thin).
- recorrelation_sweep: passes the call doc's stored values explicitly
  (whole-call re-link, link-only, so a borrowed severity cannot create).
- intelligence.py: SCENE DETECTION prompt tightened toward one scene
  (server-26#5, partial) - MULTIPLE only for genuinely separate events,
  "when unsure, one scene", plus a not-a-new-scene list.
- test_incident_identity.py: +2 regression tests mirroring the #87 test.

Full c2-core suite green (295 passed). #5 prompt change is unmeasured -
needs a scoped correlation-only window. Known remaining legs, tracked
separately: llm_correlator._call_block still reads the whole-call
transcript per scene; content-divergence veto skips on a None embedding.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 13:01:59 -04:00
Logan CusanoandClaude Opus 5 85393bdb26 ci: retrigger deploy after registry token expired mid-build
Build & Deploy / Build & push images (push) Successful in 6m20s
Build & Deploy / Deploy to VM (push) Successful in 1m59s
Build & Deploy / Report a failed deploy (push) Skipped
Run 570 (8b6c170) pushed c2-core successfully, then failed on
discord-bot with "failed to authorize: failed to fetch oauth token:
unauthorized" ~20s later, using the same credential. That is a
short-lived registry token expiring mid-run, not an invalid one.

Build job failure skipped "Deploy to VM", so 8b6c170 -- which closes
the viewer-triggerable OpenAI spend on incident summarize
(server-26#81) -- never reached production. Prod stayed on b722223
with the spend leak open.

No code change. This commit exists only to re-run the pipeline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 02:49:05 -04:00
Logan CusanoandClaude Opus 5 8b6c170265 Close viewer-triggerable OpenAI spend on incident summarize (server-26#81)
Build & Deploy / Build & push images (push) Failing after 1m58s
Build & Deploy / Deploy to VM (push) Skipped
Build & Deploy / Report a failed deploy (push) Successful in 2s
POST /incidents/{id}/summarize was gated by require_service_or_firebase_token,
which accepts any authenticated Firebase user including role "viewer". That
route spends OpenAI credits via the background summarizer. The call-side
equivalent was already moved to require_admin_token; this brings the incident
side in line with it.

The frontend's two "summarize now" buttons on the incident detail page are
already gated behind isAdmin, so this backend change matches existing UI
behavior exactly and does not break any viewer/operator surface — it only
closes direct-API access for non-admins.

Swept every other route in incidents.py: list/get are reads with no spend and
correctly stay open to any signed-in user; create/update/delete/link/unlink
were already require_admin_token. No other sibling route needed changing.

Adds test_incident_summarize_auth.py pinning the dependency wiring directly
(the convention used in test_admin_feature_flags.py), so a future revert back
to the weak dependency fails a test immediately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 02:45:08 -04:00
Logan CusanoandClaude Opus 5 b7222230bd frontend: label machine-generated output and unbuilt entitlements (Gate A)
Build & Deploy / Build & push images (push) Successful in 4m25s
Build & Deploy / Deploy to VM (push) Successful in 1m55s
Build & Deploy / Report a failed deploy (push) Skipped
Gate A (BUSINESS_MODEL.md, board minutes #42, dated to today by minutes #79
decision 14) blocks putting a price or an unbuilt entitlement claim on a
surface a reader can see, and requires that unverified machine assertions be
labelled as such on the same screen as the assertion.

The pricing leg was already met — /pricing and both homepage CTAs stopped
quoting the invented catalog. Condition A2 was not: a search of the whole
frontend for a "machine-generated" or "unverified" qualifier returned zero
hits. Every transcript, summary, title, location, unit list and vehicle list
is pipeline output that no human reviews, and entity-name accuracy in those
transcripts has never been measured (server-26#48) — yet all of it was
rendered to the reader as plain fact. Unqualified machine assertions about
real incidents and real people is the exposure Gate A exists to stop.

A2 — one reusable element, components/ui/MachineOutputNotice.tsx, rendered on
the same screen as the output (a footnote elsewhere does not satisfy A1's
"same screen" standard). Three variants for three shapes of surface, all
saying the same thing; the "popup" variant uses fixed grays because a Leaflet
popup is stock-white in both themes. Covered:

  - incident detail: under the summary (covers summary, title, location,
    units on scene/cleared, vehicles, tags) and above the call spine
  - incident list: above the timeline groups
  - Archive (/calls): above the transcript rows
  - node detail: above the Recent Calls table
  - Watch//alerts: above the events table, whose Snippet column is transcript
    text and whose keyword match was made against it
  - Live map: the desktop incident rail, pinned above the scroll area so it
    cannot be scrolled off the screen it qualifies; the mobile drawer; the
    incident marker popup; the incident-path stop popup
  - /systems: the source-call transcript preview
  - /features: the two marketing sections that describe the AI pipeline

A1 — components/ui/UnbuiltMarker.tsx marks a claim unbuilt inline:

  - /faq: the retention answer promised 7/90/365-day windows. There is no TTL
    and no deletion sweep anywhere in the product (server-26#44), so the
    answer now states plainly that nothing is deleted automatically and marks
    per-plan retention as not yet available.
  - /settings/billing: the plan cards' claims — custom retention, SSO/SAML,
    uptime SLA, data residency — are marked not-yet-available next to the plan
    that makes them.

Labelling only. No retention, SSO, SLA or residency was built; no billing,
Stripe or checkout code was touched (Gate B still bars charging anyone); no
price was added anywhere; no Python was touched. Both themes verified against
the light-mode !important overrides in globals.css, which are untouched.

tsc --noEmit clean.

Refs: server-26#46, server-26#44, server-26#48

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 02:47:40 -04:00
Logan Cusano bdb57ae75a correlator: stop non-primary scenes inheriting the call doc's pin (#87)
Build & Deploy / Build & push images (push) Successful in 4m30s
Build & Deploy / Deploy to VM (push) Successful in 1m47s
Build & Deploy / Report a failed deploy (push) Skipped
_build_context fell back to call_doc.get("location_coords") whenever a
scene passed no coordinates of its own. One radio call can be split
into several scenes, but only the primary scene's geocode is ever
written to the call doc — so every non-primary scene silently
inherited the primary scene's pin. That fabricated location_proximity,
the strongest accept signal the correlator has, for a scene that had
no location at all, and drove it into the primary scene's incident on
a pin it never had.

Drop the fallback: coords = location_coords. A scene with no location
is now correctly judged thin, cannot win the location path, cannot
supply call_coords to _call_fits_incident, and cannot seed
_find_cross_system_parent.

recorrelation_sweep.py, the only other caller of correlate_call, was
verified to already pass both location and location_coords explicitly
from the call doc, so the fallback there was a no-op and this change
is behavior-preserving for that path.

Adds test_a_scene_with_no_location_does_not_inherit_the_call_docs_pin
to test_incident_identity.py, pinning ctx["coords"] is None and
ctx["is_thin_call"] is True when location=None but the call doc
carries a location_coords.

Ref: server-26#87
2026-08-31 02:45:31 -04:00
Logan CusanoandClaude Opus 5 29c2fb11b9 Ignore drb-telegram-bot/ — out of scope, not a deployed service
Build & Deploy / Build & push images (push) Successful in 4m10s
Build & Deploy / Deploy to VM (push) Successful in 1m55s
Build & Deploy / Report a failed deploy (push) Skipped
Scaffolding for a service that does not run and is not in compose. It has sat
untracked across four unattended runs, each of which had to decide again
whether to commit or delete someone else's work. Declaring it out of scope
ends that. server-26#56.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 23:28:29 -04:00
Logan CusanoandClaude Opus 5 865b5b4317 Close the /admin/features side-door that needed a container shell to flip AI spend
Build & Deploy / Build & push images (push) Successful in 4m15s
Build & Deploy / Deploy to VM (push) Successful in 1m56s
Build & Deploy / Report a failed deploy (push) Skipped
Board minutes #62 Decision 2 (server-26#64), due 2026-08-31. CTO draft #60
finding 1 and CISO draft #61 finding 3 reached this independently.

GET/PUT /admin/features accepted only a Firebase admin token, so the unattended
runbook had no headless path and SSHed into the c2-core container to write
config/ai_features with the admin SDK. Moving a platform-wide AI cost switch
required a full container shell, and set_flags() wrote no audit entry either
way, so a flag flip was unattributable however it happened.

- New agent_service_key (AGENT_SERVICE_KEY), deliberately separate from the
  Discord bot's service_key. Sharing one key would collapse two principals into
  a single unattributable identity in every log line, and the bot has no
  business flipping AI flags regardless.
- require_agent_key_or_admin accepts the agent key or a Firebase admin, and
  rejects the Discord key. The "key is configured" guard is load-bearing:
  compare_digest("", "") is a match, so a deployment that never set the key
  would otherwise accept an empty credential.
- set_flags() writes an audit_log entry with before/after values and the actor,
  wrapped so an audit failure cannot lose the flag write or 500 the route.
- Cascade helper sets the global doc and every system carrying an ai_flags
  override in one call. A global False already beats everything, but a system
  False beats a global True, so turning AI *on* could half-apply and leave a
  radio system hot after shutoff. It scans for the override rather than
  hardcoding the two known system IDs, so a new system cannot silently defeat
  it.
- cascade defaults to False. PUT /systems/{id}/ai-flags and the AiFlagsPanel
  toggle mean a per-system override is deliberate operator intent; cascading by
  default would erase it on any unrelated global flip. The runbook opts in.

Issue items 5 and 6 (retiring the SSH path from drb-worksession.md) are NOT
done here and the runbook is untouched. The credential does not exist in
production yet, so the SSH path is still the only one that works; retiring it
now would break the next unattended run. Owner activation is recorded on #64.

Tests 273 -> 289.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 02:52:00 -04:00
Logan Cusano 0635de8dac Stop alert webhooks putting raw transcripts in a third-party channel
Build & Deploy / Build & push images (push) Successful in 4m10s
Build & Deploy / Deploy to VM (push) Successful in 1m44s
Build & Deploy / Report a failed deploy (push) Skipped
Alert dispatch attached a 200-character raw transcript snippet to the
alert_events document and POSTed the same text to the org's Discord
webhook, with no redaction of any kind. Board minutes #42 ratified that
person names are suppressed on every surface until E&O is bound, and a
Discord channel is the least recoverable surface there is: once the text
lands we do not own it, cannot unsend it, and cannot audit who read it.

Raw transcript text now requires two independent gates, both closed by
default:

  1. alert_transcript_snippet_enabled -- an operator switch in config,
     set from the environment.
  2. alert_snippet_opt_in on the org document -- the customer's own
     explicit consent.

Gate 1 is not redundant. The frontend reads and writes Firestore directly
from the browser, so the org flag alone would let an org owner opt
themselves into receiving person names lifted from live public-safety
traffic. Capability is the operator's to grant; consent is the org's.

The gate fails closed on a Firestore error and on a call with no org
(a pre-tenancy node that has not been backfilled) -- a less informative
alert is cheap, an unrecallable disclosure is not. Alerting itself is
unchanged: the webhook still fires and still names the rule, the
talkgroup and the matched keywords.

This does not wait on the Gate B3 redactor (#43, 2026-09-30). The
snippet was a convenience field and needed no redactor to withhold.

Tests assert the person name in a sample transcript does not appear in
either the outbound payload or the Firestore write, in every combination
of the two gates.

Closes server-26#85. Refs #42, #43, #48.
2026-08-29 02:42:31 -04:00
Logan CusanoandClaude Opus 5 3df427f914 Scope Gate B3 so the owner can stop being blocked on it (server-26#43)
Build & Deploy / Build & push images (push) Successful in 4m6s
Build & Deploy / Deploy to VM (push) Successful in 1m47s
Build & Deploy / Report a failed deploy (push) Skipped
Board minutes #62 decision 6d bars showing live data to a prospect until #43 is
scoped. The conversation count is 0 of 12 with a hard checkpoint on 2026-09-05,
so the scoping document is worth more this week than the implementation, which
is not due until 2026-09-30.

Corrects a premise in #43: the extraction prompt carries no person-name entity
field, so "entities are already extracted" does not hold. Redaction has to work
on raw free text, and that is most of the estimate.

Redaction is specified at write time rather than read time, because the frontend
reads Firestore directly and rules cannot mask a field -- redacting only in the
API would leave the raw document readable in the browser.

EMS exclusion needs a per-talkgroup flag. ai_flags is per-system, and real
systems carry EMS alongside police and fire.

Refs server-26#43, #42, #62, #66, #85.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 03:09:41 -04:00
41 changed files with 1440 additions and 54 deletions
+3
View File
@@ -44,3 +44,6 @@ recordings/
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# Out of scope - not a deployed service (server-26#56)
drb-telegram-bot/
+42
View File
@@ -0,0 +1,42 @@
# Gate B3 (server-26#43) -- Engineering Scope
Owner: CTO. Scope only -- no implementation. Ship date unchanged: 2026-09-30.
## 1. The two defaults, testable
- EMS exclusion: for any call whose talkgroup is classified medical, the AI pipeline (Whisper STT + GPT-4o-mini intelligence.py extraction + correlation) must not run. Opt-in only via a per-customer contract flag. Test: upload a call on a talkgroup marked medical, calls/{id}.transcript stays null, no incident_ids.
- Name suppression: no surface serving a calls or incidents document (API, frontend render, alert webhook, future export/Discord/API-key tiers) may return an unredacted transcript, summary, or title to any account -- public, comped, or paid -- until an E&O policy is bound (#43 comment 1). Test: same document, two reads -- direct Firestore read and /incidents/{id} API read -- both redacted.
## 2. Talkgroup-granularity gap
feature_flags.py:80-107 (resolve_flags) only layers a per-system ai_flags dict (routers/systems.py:107-129, flat {flag_name: bool}, no talkgroup key) on top of the global default. DEFERREDs own entry for this file says the fix shape is talkgroup_ai_flags: {tgid: {...}} on the system doc, consulted where flag() is built. That field does not exist. Without it, "EMS excluded, rest of the system processed" is not buildable -- the flag is all-on/all-off per system, and most systems mix EMS with police/fire dispatch under one system_id (the exact case BUSINESS_MODEL section 5.5 is trying to protect against). This data-model change is a hard prerequisite, not an enhancement: add talkgroup_ai_flags: {tgid: {stt_enabled, correlation_enabled}} to the system doc, consult it in resolve_flags() before the system-level flag, default every unclassified talkgroup on a system that has at least one confirmed-medical talkgroup to excluded until explicitly classified.
## 3. Redaction design -- write time, not read time
Pick: compute and store a redacted copy alongside the raw one, at extraction/summarization time. Two sentences: the frontend reads Firestore directly for calls/incidents (CLAUDE.md gotcha -- middleware.ts is UX-only, Firestore rules are the real boundary), and Firestore rules can allow/deny a whole document but cannot mask one field inside it -- so a redaction step that only runs inside c2-cores API responses leaves the exact same unredacted transcript/summary/title readable by any authenticated browser via onSnapshot/getDocs against the collection directly. The only enforcement point that actually covers both paths is: the client-readable document never contains the unredacted field. Raw content moves to a field/subcollection excluded from client-facing Firestore rules and readable only server-side by c2-core (satisfies "never deletion, reversible the day a policy binds" -- #43 comment 1).
incident.title is template-composed from tag/location/talkgroup (incident_correlator.py:380-389), not LLM freeform -- already name-free by construction, no redaction needed there. The actual carriers are calls.transcript (models.py:165) and the GPT summary (summarizer.py:146-161, built directly from raw transcripts, no name-avoidance instruction today).
## 4. A premise in #43 does not hold
#43s body says "entities are already extracted, so the redaction has a data source to work from." Not true as of this read. intelligence.pys extraction prompt (_PROMPT_TEMPLATE, lines 24-72) has no person-name field -- it extracts tags, incident_type, location, vehicles, units, cleared_units, severity. units is explicitly restricted to "unit IDs or officer numbers... never infer or guess" (line 57) -- radio callsigns, not private-citizen names. There is no structured entity to redact against. Redaction must run against unstructured free text (transcript + GPT summary), via a new regex/NER-style pass with its own unmeasured false-negative rate -- the same class of problem #48 raised about the extractor, one level down, on code that does not exist yet.
## 5. Surface inventory (complete)
- drb-frontend: app/incidents/page.tsx, app/incidents/[id]/page.tsx, app/calls/page.tsx, components/CallRow.tsx, components/CallSpineEntry.tsx -- render title/summary/transcript. Every one is backed by a direct Firestore listener per the section 3 gotcha, not just the page component -- any future onSnapshot/getDocs against calls/incidents inherits the same exposure and must be audited, not assumed covered.
- drb-c2-core API: routers/calls.py, routers/incidents.py (JSON responses).
- drb-c2-core/app/internal/alerter.py:56,68 -- transcript_snippet (200 chars, raw, unredacted today) written into alert_events and POSTed to the customers own Discord webhook. This is the live, sellable Pro-tier "Alerting" feature (BUSINESS_MODEL section 3.4 item 1) -- highest-priority surface, it is the actual product hook for the beachhead segment.
- drb-server-discord-bot: checked app/commands/radio.py, app/commands/trips.py -- embeds today are node status/help/trip content only, no incident transcript/summary rendering exists yet. Nothing to redact today; must inherit this design the day incident-to-Discord posting ships.
- drb-telegram-bot: app/handlers/__init__.py is a stub, no incident-surfacing code exists. Same note as above.
- Not yet built, but must inherit the design when built: CSV export, Network-tier API access (lib/apiKeys.ts is an in-memory stub per DEFERRED.md).
## 6. Out of scope for #43
- Raw-audio/live-relay exclusion of EMS talkgroups -- the ruling excludes them from the AI pipeline only, not from live audio/Discord voice relay.
- Building an accurate NER model -- a heuristic/regex redactor is scope; measuring or improving its accuracy is a follow-on issue (mirrors #48, on the redactor instead of the extractor).
- Retroactive redaction of historical calls/incidents already in Firestore (no backfill infra exists -- same unscoped-backfill pattern already logged in DEFERRED.md for _verified_pin). Tracked as a new follow-on issue at ship time, not built now.
- A UI for classifying talkgroups as EMS/medical beyond a minimal toggle reusing the existing per-system ai-flags PUT route pattern (routers/systems.py:107).
## 7. Needs a CEO/owner ruling
- Urgent -- is the comped (friends/family) tier suspended today? #43 comment 1 states suppression must hold "on every surface -- public, comped and paid," and #79 comment says no login proceeds until this ships -- but the comped tier is described in BUSINESS_MODEL section 3.2 as already live with "todays live full product," unredacted. Either comped access is in active breach of the ruling right now, or it is meant to be paused pending this ship date. My recommendation: pause comped access to incident detail/transcript views (or accept and log the breach explicitly) until #43 ships -- silently continuing is worse than either choice on record.
- How is a talkgroup classified EMS/medical? Recommend: name-pattern heuristic (reusing the existing _TG_SUFFIX_RE EMS/rescue matching in intelligence.py:101-108) as the default classification, manual override in the system editor, and default-exclude on no match rather than default-include -- a false negative here is the exact liability #43 exists to prevent.
- Does exclusion/redaction apply retroactively to already-processed calls? Recommend: prospective only for 2026-09-30; backfill is a separate follow-on issue (see section 6).
## 8. Effort estimate vs 2026-09-30
Roughly 6-10 engineering-days, agent-buildable (no human/contractor per GOALS.md), contingent on the section 7 rulings landing quickly -- they gate the design, not just the code:
- Talkgroup-flag data model + resolve_flags() wiring: ~1 day.
- Minimal EMS-classification toggle (reuse ai-flags PUT pattern): ~1-2 days.
- Redacted-copy storage split + Firestore rules change + regex/heuristic redactor + alerter.py snippet redaction + audit of all direct Firestore listeners in frontend: ~4-6 days -- this is the long pole, because section 4 means it is new code, not a wire-up of an existing field.
#48 does not block this. #43 comment 1 is explicit: the 200-call accuracy measurement "can no longer decide whether names are published, because they are suppressed regardless. It remains a Gate B condition for other reasons." Sequence independently.
+8
View File
@@ -19,6 +19,14 @@ services:
- mosquitto_data:/mosquitto/data - mosquitto_data:/mosquitto/data
- mosquitto_certs:/mosquitto/certs - mosquitto_certs:/mosquitto/certs
# c2-core takes ALL of its configuration from ./drb-c2-core/.env — there is
# deliberately no `environment:` block here. An entry in that block wins over
# env_file, so listing a key here (e.g. AGENT_SERVICE_KEY=${AGENT_SERVICE_KEY})
# would let an unset top-level .env silently blank out a value the owner had
# correctly pasted into drb-c2-core/.env. New settings go in
# drb-c2-core/.env.example and, for the VM, in
# infra/ansible/roles/deploy/templates/c2-core.env.j2 + vault.yml.
# AGENT_SERVICE_KEY (server-26#64) is configured that way.
c2-core: c2-core:
image: ${REGISTRY}/c2-core:${TAG:-latest} image: ${REGISTRY}/c2-core:${TAG:-latest}
build: ./drb-c2-core build: ./drb-c2-core
+12
View File
@@ -37,3 +37,15 @@ EMBEDDING_SIMILARITY_THRESHOLD=0.82
# (POST /nodes/enroll). Shared across every node — NOT a per-node secret. # (POST /nodes/enroll). Shared across every node — NOT a per-node secret.
# Generate with: openssl rand -hex 32 # Generate with: openssl rand -hex 32
ENROLLMENT_TOKEN= ENROLLMENT_TOKEN=
# Shared key the Discord bot presents to reach C2 without Firebase.
# Generate with: openssl rand -hex 32
SERVICE_KEY=
# Agent/automation key for the unattended work session's headless routes
# (GET/PUT /admin/features). DELIBERATELY a different value from SERVICE_KEY —
# reusing the bot's key would make both principals indistinguishable in
# audit_log, which is the whole point of server-26#64. Leave blank to keep the
# agent path closed; the routes still take a Firebase admin token either way.
# Generate with: openssl rand -hex 32
AGENT_SERVICE_KEY=
+24
View File
@@ -61,6 +61,15 @@ class Settings(BaseSettings):
# against the talkgroup's own anchor instead of stuffing every road in town # against the talkgroup's own anchor instead of stuffing every road in town
# into the prompt, so cost scales with location nouns rather than call volume. # into the prompt, so cost scales with location nouns rather than call volume.
place_verification_enabled: bool = True place_verification_enabled: bool = True
# Raw transcript text in alert payloads (server-26#85). Default CLOSED.
# Board minutes #42 suppress person names on every surface until E&O is
# bound, and an alert webhook is the least recoverable surface there is:
# once the text is in a Discord channel we do not own it, cannot unsend
# it, and cannot audit who read it. This switch is the operator-level
# gate and is deliberately NOT reachable from the app -- the per-org
# opt-in alone would let an org owner self-serve their way to somebody
# else's PII. Both gates must be open before any snippet leaves.
alert_transcript_snippet_enabled: bool = False
place_verify_max_per_call: int = 3 place_verify_max_per_call: int = 3
# How close a candidate has to sound before it may rewrite a transcript. # How close a candidate has to sound before it may rewrite a transcript.
# Below this, Places Text Search will confidently hand back the nearest # Below this, Places Text Search will confidently hand back the nearest
@@ -132,6 +141,21 @@ class Settings(BaseSettings):
# Internal service key — allows server-side services (discord bot) to call C2 without Firebase # Internal service key — allows server-side services (discord bot) to call C2 without Firebase
service_key: Optional[str] = None service_key: Optional[str] = None
# Automation/agent service key — the unattended work-session agent's own
# credential for the headless routes it needs (currently GET/PUT
# /admin/features).
#
# DELIBERATELY SEPARATE from service_key above, not a second consumer of
# it. service_key is the Discord bot's, and it is handed to a process that
# relays radio traffic to a chat server; sharing it here would make "the
# bot" and "the agent" the same principal in every log line and audit
# entry, so a global AI-cost flag flip could never be attributed to whoever
# actually made it. Two keys, two identities (server-26#64 item 1).
#
# Unset means the agent path is simply closed — the routes still accept a
# Firebase admin token. Generate with: openssl rand -hex 32
agent_service_key: Optional[str] = None
# Fleet-wide token edge nodes present to POST /nodes/enroll on first boot. # Fleet-wide token edge nodes present to POST /nodes/enroll on first boot.
# Not a per-node secret — see routers/enrollment.py for why a leaked copy # Not a per-node secret — see routers/enrollment.py for why a leaked copy
# of this alone can't steal an already-approved node's key. # of this alone can't steal an already-approved node's key.
+46 -1
View File
@@ -6,11 +6,15 @@ talkgroup ID, tags, and transcript. On a match:
1. Creates an AlertEvent document in Firestore. 1. Creates an AlertEvent document in Firestore.
2. Optionally POSTs a Discord webhook message if the rule has one configured. 2. Optionally POSTs a Discord webhook message if the rule has one configured.
Raw transcript text is withheld from both by default -- see _snippet_allowed
and server-26#85.
Never raises — failures are logged as warnings so the pipeline always completes. Never raises — failures are logged as warnings so the pipeline always completes.
""" """
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional from typing import Optional
from app.config import settings
from app.internal.logger import logger from app.internal.logger import logger
from app.internal import firestore as fstore from app.internal import firestore as fstore
@@ -47,13 +51,17 @@ async def check_and_dispatch(
logger.warning(f"Alerter: could not load rules: {e}") logger.warning(f"Alerter: could not load rules: {e}")
return return
# Loop-invariant: every rule here belongs to the same org, so the opt-in is
# resolved once rather than per match.
snippet_allowed = await _snippet_allowed(org_id)
for rule in rules: for rule in rules:
matched_keywords = _match_rule(rule, talkgroup_id, tags, transcript) matched_keywords = _match_rule(rule, talkgroup_id, tags, transcript)
if not matched_keywords: if not matched_keywords:
continue continue
alert_id = str(uuid.uuid4()) alert_id = str(uuid.uuid4())
snippet = _snippet(transcript) snippet = _snippet(transcript) if snippet_allowed else None
now = datetime.now(timezone.utc).isoformat() now = datetime.now(timezone.utc).isoformat()
event = { event = {
"alert_id": alert_id, "alert_id": alert_id,
@@ -85,6 +93,43 @@ async def check_and_dispatch(
await _post_webhook(webhook_url, rule.get("name", ""), talkgroup_name, matched_keywords, snippet) await _post_webhook(webhook_url, rule.get("name", ""), talkgroup_name, matched_keywords, snippet)
async def _snippet_allowed(org_id: Optional[str]) -> bool:
"""
Whether raw transcript text may be attached to an alert (server-26#85).
Two gates, both of which must be open:
1. ``settings.alert_transcript_snippet_enabled`` -- the operator switch,
default False, set from the environment and unreachable from the app.
2. ``alert_snippet_opt_in`` on the org document -- the customer's own
explicit, contractual opt-in.
Gate 1 exists because gate 2 alone is not a real control: the frontend
reads and (per the Firestore rules, not ``auth.py``) can write org state
directly from the browser, so an org owner could otherwise opt themselves
into receiving person names lifted from live public-safety traffic. Board
minutes #42 suppress names on every surface until E&O is bound.
Fails CLOSED on any error, and on a call with no org (a pre-tenancy node
that has not been backfilled), because the cost of wrongly withholding a
snippet is a less informative alert and the cost of wrongly emitting one
is unrecallable disclosure to a third party.
"""
if not settings.alert_transcript_snippet_enabled:
return False
if not org_id:
return False
try:
org = await fstore.doc_get("organizations", org_id)
except Exception as e:
logger.warning(
f"Alerter: could not read snippet opt-in for org={org_id}, "
f"withholding transcript: {e}"
)
return False
return bool((org or {}).get("alert_snippet_opt_in"))
def _match_rule( def _match_rule(
rule: dict, rule: dict,
talkgroup_id: Optional[int], talkgroup_id: Optional[int],
+68
View File
@@ -220,6 +220,74 @@ async def require_service_key_or_admin(
return decoded return decoded
# ---------------------------------------------------------------------------
# Automation / agent principal
# ---------------------------------------------------------------------------
# Identity written into audit_log when the agent key is what authenticated a
# request. A Firebase admin gets their own uid/email instead, so the two are
# always distinguishable after the fact — which is the point.
AGENT_PRINCIPAL_UID = "agent-service"
AGENT_PRINCIPAL_EMAIL = "agent-service@drb.internal"
async def require_agent_key_or_admin(
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
) -> dict:
"""Accept either the agent service key or a Firebase admin token.
Deliberately does NOT accept ``settings.service_key``. That key belongs to
the Discord bot, and honouring it here would collapse two principals into
one unattributable identity in every log line and audit entry — the exact
thing server-26#64 exists to end. The bot has no business flipping
platform-wide AI flags either way.
Exists so the unattended runbook can flip AI flags over HTTP instead of
SSHing into the container and writing ``config/ai_features`` with the admin
SDK, which needs a full container shell to move a cost switch.
The ``settings.agent_service_key and ...`` guard is load-bearing, not
stylistic: ``secrets.compare_digest("", "")`` is a MATCH, so any form of
``compare_digest(token, settings.agent_service_key or "")`` would turn a
deployment that never configured the key into one that accepts an empty
credential. Check the key is configured first and never substitute a
placeholder. (``require_service_key`` states the same intent by raising
503 when unset; both are correct, this one just stays open to admins.)
"""
if not credentials:
raise HTTPException(status_code=401, detail="Missing authorization token")
token = credentials.credentials
if settings.agent_service_key and secrets.compare_digest(token, settings.agent_service_key):
return {
"service": True,
"principal": "agent",
"uid": AGENT_PRINCIPAL_UID,
"email": AGENT_PRINCIPAL_EMAIL,
}
try:
decoded = firebase_auth.verify_id_token(token)
except Exception:
raise HTTPException(status_code=401, detail="Invalid or expired token")
if get_role(decoded) != "admin":
raise HTTPException(status_code=403, detail="Admin access required")
return decoded
def describe_actor(principal: dict) -> tuple[str, str]:
"""Return ``(actor_uid, actor_email)`` for an audit entry.
Works for any credential shape the dependencies above produce, so an audit
call site never has to switch on principal type itself.
"""
if principal.get("principal") == "agent":
return AGENT_PRINCIPAL_UID, AGENT_PRINCIPAL_EMAIL
if principal.get("service"):
return "service", "service@drb.internal"
if principal.get("node"):
node_id = principal.get("node_id") or "unknown"
return f"node:{node_id}", ""
return principal.get("uid") or "unknown", principal.get("email") or ""
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Simple in-memory sliding-window rate limiter # Simple in-memory sliding-window rate limiter
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+123 -4
View File
@@ -63,18 +63,137 @@ async def get_flags() -> dict[str, bool]:
return dict(_cache) return dict(_cache)
async def set_flags(updates: dict[str, bool]) -> dict[str, bool]: async def _cascade_to_systems(clean: dict[str, bool]) -> tuple[list[dict], list[dict]]:
"""Write flag updates to Firestore and invalidate the cache.""" """Clear per-system ``ai_flags`` overrides for the keys just set globally.
global _cache, _cache_ts
Returns ``(changes, errors)``.
Why clearing rather than overwriting with the new value: an override that
stays present, merely agreeing with the global switch for now, defeats the
NEXT flip exactly the same way. Removing it makes the system inherit, which
is the same semantics the human-facing route already offers
(``PUT /systems/{id}/ai-flags`` with null → "clear override, inherit
global").
Systems are discovered by scanning for documents that actually carry an
``ai_flags`` map — never a hardcoded id list. Two systems carry overrides
today; a third added tomorrow would silently defeat a global shutoff if
this were pinned to the current pair.
"""
changes: list[dict] = []
errors: list[dict] = []
systems = await fstore.collection_list("systems")
for system in systems:
sid = system.get("system_id")
ai_flags = system.get("ai_flags")
# Only documents that actually carry the map. A system with no
# overrides already inherits, so there is nothing to cascade to.
if not sid or not isinstance(ai_flags, dict) or not ai_flags:
continue
removed = {k: ai_flags[k] for k in clean if k in ai_flags}
if not removed:
continue
remaining = {k: v for k, v in ai_flags.items() if k not in clean}
try:
await fstore.doc_update("systems", sid, {"ai_flags": remaining})
except Exception as e:
# Report rather than swallow: a half-applied cascade is the exact
# failure mode this helper exists to prevent, so it must be visible
# in the log and the audit entry.
logger.error(f"Feature flags: cascade to system '{sid}' failed ({e})")
errors.append({"system_id": sid, "error": str(e)})
continue
changes.append({
"system_id": sid,
"cleared_overrides": removed,
"now_inherits": {k: clean[k] for k in removed},
})
return changes, errors
async def set_flags(
updates: dict[str, bool],
actor: tuple[str, str] | None = None,
cascade: bool = False,
) -> dict[str, bool]:
"""Write flag updates to Firestore, invalidate the cache, and audit it.
``actor`` is ``(actor_uid, actor_email)`` — see auth.describe_actor. It is
optional so existing callers keep working; an unattributed flip is logged
as "unknown" rather than not logged at all.
``cascade`` also clears the matching per-system ``ai_flags`` overrides, so
one call is a total flip. Defaults to False deliberately — see the route's
comment in routers/admin.py.
Returns the resulting global flags dict, unchanged in shape: the admin UI
(drb-frontend/lib/c2api.ts setFeatureFlags) types the response as
Record<string, boolean>, so cascade/audit detail goes to the log and the
audit entry rather than into this payload.
"""
global _cache_ts
clean = {k: bool(v) for k, v in updates.items() if k in _DEFAULTS} clean = {k: bool(v) for k, v in updates.items() if k in _DEFAULTS}
if not clean: if not clean:
raise ValueError(f"No recognised flag keys in update: {list(updates)}") raise ValueError(f"No recognised flag keys in update: {list(updates)}")
# Force a fresh read for the "before" side of the audit entry: the TTL
# cache can be up to _TTL seconds stale, and a wrong previous value in an
# audit log is worse than none.
_cache_ts = 0.0
before = await get_flags()
await fstore.doc_set(_COLLECTION, _DOC_ID, clean) await fstore.doc_set(_COLLECTION, _DOC_ID, clean)
_cache_ts = 0.0 # force re-read on next get_flags() _cache_ts = 0.0 # force re-read on next get_flags()
logger.info(f"Feature flags updated: {clean}") logger.info(f"Feature flags updated: {clean}")
return await get_flags()
cascaded: list[dict] = []
cascade_errors: list[dict] = []
if cascade:
cascaded, cascade_errors = await _cascade_to_systems(clean)
logger.info(
f"Feature flags: cascaded {list(clean)} to {len(cascaded)} system(s), "
f"{len(cascade_errors)} error(s)"
)
after = await get_flags()
# The audit entry is a record OF the write, never a precondition for it.
# audit_log lives in the same Firestore that just accepted the flag write,
# so a failure here is nearly always transient — losing the flip (or 500ing
# a route that already succeeded, which invites a retry that flips it back)
# would be a far worse outcome than an unrecorded flip that is still in the
# service log above.
try:
# Deferred import: app.internal.audit pulls in firestore, and this
# module is imported from router module scope.
from app.internal import audit
actor_uid, actor_email = actor or ("unknown", "")
changed = {
k: {"from": before.get(k), "to": after.get(k)}
for k in clean
if before.get(k) != after.get(k)
}
await audit.write_audit(
actor_uid=actor_uid,
actor_email=actor_email,
action="feature_flags.update",
details={
"requested": clean,
"changed": changed,
"before": before,
"after": after,
"cascade": cascade,
"cascaded_systems": cascaded,
"cascade_errors": cascade_errors,
},
)
except Exception as e:
logger.error(f"Feature flags: audit write failed ({e}) — flag change stands")
return after
async def resolve_flags(system_id: str | None): async def resolve_flags(system_id: str | None):
@@ -668,10 +668,17 @@ async def correlate_call(
vehicles: Optional[list[str]] = None, vehicles: Optional[list[str]] = None,
cleared_units: Optional[list[str]] = None, cleared_units: Optional[list[str]] = None,
reassignment: bool = False, reassignment: bool = False,
embedding: Optional[list] = None,
severity: Optional[str] = None,
) -> Optional[str]: ) -> Optional[str]:
""" """
Link call_id to an existing incident or create a new one. Link call_id to an existing incident or create a new one.
Thin wrapper: builds context → runs rules decision → commits. Thin wrapper: builds context → runs rules decision → commits.
``embedding`` and ``severity`` are the SCENE's own values (server-26#80/#95).
Callers that re-correlate a whole call rather than a scene — the
recorrelation sweep — pass the call doc's stored values explicitly; they are
no longer read from the doc inside _build_context.
""" """
ctx = await _build_context( ctx = await _build_context(
call_id=call_id, units=units, vehicles=vehicles, cleared_units=cleared_units, call_id=call_id, units=units, vehicles=vehicles, cleared_units=cleared_units,
@@ -679,6 +686,7 @@ async def correlate_call(
system_id=system_id, talkgroup_id=talkgroup_id, talkgroup_name=talkgroup_name, system_id=system_id, talkgroup_id=talkgroup_id, talkgroup_name=talkgroup_name,
tags=tags, incident_type=incident_type, location=location, tags=tags, incident_type=incident_type, location=location,
reassignment=reassignment, create_if_new=create_if_new, reassignment=reassignment, create_if_new=create_if_new,
embedding=embedding, severity=severity,
) )
decision = _run_decision(ctx) decision = _run_decision(ctx)
return await _apply_and_log(decision, ctx) return await _apply_and_log(decision, ctx)
@@ -700,6 +708,8 @@ async def preview_correlation(
vehicles: Optional[list[str]] = None, vehicles: Optional[list[str]] = None,
cleared_units: Optional[list[str]] = None, cleared_units: Optional[list[str]] = None,
reassignment: bool = False, reassignment: bool = False,
embedding: Optional[list] = None,
severity: Optional[str] = None,
) -> dict: ) -> dict:
""" """
Run the rules engine and return the decision WITHOUT committing to Firestore. Run the rules engine and return the decision WITHOUT committing to Firestore.
@@ -720,6 +730,7 @@ async def preview_correlation(
system_id=system_id, talkgroup_id=talkgroup_id, talkgroup_name=talkgroup_name, system_id=system_id, talkgroup_id=talkgroup_id, talkgroup_name=talkgroup_name,
tags=tags, incident_type=incident_type, location=location, tags=tags, incident_type=incident_type, location=location,
reassignment=reassignment, create_if_new=create_if_new, reassignment=reassignment, create_if_new=create_if_new,
embedding=embedding, severity=severity,
) )
decision = _run_decision(ctx) decision = _run_decision(ctx)
return {"decision": decision, "ctx": ctx} return {"decision": decision, "ctx": ctx}
@@ -752,6 +763,8 @@ async def _build_context(
location: Optional[str], location: Optional[str],
reassignment: bool, reassignment: bool,
create_if_new: bool, create_if_new: bool,
embedding: Optional[list] = None,
severity: Optional[str] = None,
) -> dict: ) -> dict:
now = reference_time or datetime.now(timezone.utc) now = reference_time or datetime.now(timezone.utc)
window = timedelta(hours=settings.correlation_window_hours) window = timedelta(hours=settings.correlation_window_hours)
@@ -777,11 +790,20 @@ async def _build_context(
all_active = _drop_capped(all_active, now) all_active = _drop_capped(all_active, now)
recent = [inc for inc in all_active if _within_window_of(inc, now, window)] recent = [inc for inc in all_active if _within_window_of(inc, now, window)]
call_embedding = call_doc.get("embedding") # embedding and severity come from the SCENE being correlated, not the call
# doc — server-26#80 / #95. intelligence.py writes only the primary scene's
# embedding and severity to calls/{id}, so reading them back here handed
# every non-primary scene the primary scene's semantic vector and severity
# rung: a scene about a different event scored against the wrong incident on
# the embedding path (:1166/:1205/:1533) and could inherit a minor/moderate/
# major severity it never had, clearing the creation gate on borrowed
# weight. Same failure and same fix as the #87 coords leak directly below —
# a scene that passes none has none, and is judged thin on its own signal.
call_embedding = embedding
call_units = units if units is not None else (call_doc.get("units") or []) call_units = units if units is not None else (call_doc.get("units") or [])
call_vehicles = vehicles if vehicles is not None else (call_doc.get("vehicles") or []) call_vehicles = vehicles if vehicles is not None else (call_doc.get("vehicles") or [])
call_cleared = cleared_units if cleared_units is not None else (call_doc.get("cleared_units") or []) call_cleared = cleared_units if cleared_units is not None else (call_doc.get("cleared_units") or [])
call_severity = call_doc.get("severity") or "routine" call_severity = severity or "routine"
# A string that is not a place is not a location anywhere downstream — not # A string that is not a place is not a location anywhere downstream — not
# in the fit tests, not in the thin-call test, not in the LLM prompt, and # in the fit tests, not in the thin-call test, not in the LLM prompt, and
# not on the incident. Its coordinates go with it: coords are geocoded # not on the incident. Its coordinates go with it: coords are geocoded
@@ -789,7 +811,14 @@ async def _build_context(
location = clean_location(location) location = clean_location(location)
if location is None: if location is None:
location_coords = None location_coords = None
coords = location_coords or call_doc.get("location_coords") # NOT `location_coords or call_doc.get("location_coords")` — server-26#87.
# A radio call can be split into several scenes, and only the primary
# scene's geocode is written to the call doc. Falling back to it here
# would hand every non-primary scene the primary scene's pin, fabricating
# location_proximity (the strongest accept signal) for a scene that has
# no location of its own and driving over-merges. If a scene passes no
# coords, it has none — it is judged thin and must win on its own signal.
coords = location_coords
is_thin_call = _is_thin_call( is_thin_call = _is_thin_call(
call_units, call_vehicles, coords, tags, location, call_severity, reassignment call_units, call_vehicles, coords, tags, location, call_severity, reassignment
) )
+10 -1
View File
@@ -24,7 +24,16 @@ from app.internal.incident_correlator import clean_location, location_is_unit
_PROMPT_TEMPLATE = """You are analyzing a P25 public safety radio recording. The audio was transcribed by Whisper through a digital radio vocoder, which introduces errors. Each numbered transmission is a separate PTT press from a different radio. _PROMPT_TEMPLATE = """You are analyzing a P25 public safety radio recording. The audio was transcribed by Whisper through a digital radio vocoder, which introduces errors. Each numbered transmission is a separate PTT press from a different radio.
SCENE DETECTION: SCENE DETECTION:
A busy dispatch channel sometimes captures back-to-back conversations about multiple concurrent incidents in a single recording. Detect whether this recording contains ONE scene (all transmissions relate to a single event) or MULTIPLE scenes (clearly distinct dispatch conversations with different units being assigned, different locations, different event types). Assign short status transmissions (10-4, en route, acknowledgements) with no clear scene context to the most recent scene before them in the list. A busy dispatch channel sometimes captures back-to-back conversations about multiple concurrent incidents in a single recording. Your default is ONE scene. Return MULTIPLE scenes ONLY when the recording clearly contains two or more SEPARATE EVENTS — different incidents at different places, with no shared units, no shared subject, and no conversational thread connecting them.
These do NOT make a new scene — keep them in the same scene:
- a different unit or speaker joining the same event
- a follow-up transmission about the same job (records check, case number, tow/mileage, a unit clearing, an ETA, a location correction)
- the same subject or location being discussed again minutes later
- an administrative or status exchange that follows an event on the same channel
If you are unsure whether two exchanges are one event or two, treat them as ONE.
Assign short status transmissions (10-4, en route, acknowledgements) with no clear scene context to the most recent scene before them in the list.
Always respond with the scenes array, even for a single scene. Always respond with the scenes array, even for a single scene.
@@ -90,6 +90,11 @@ async def _recorrelate_orphan(call: dict) -> bool:
return False return False
# All data needed for correlation was stored by the first-pass extraction. # All data needed for correlation was stored by the first-pass extraction.
# embedding/severity are no longer read from the call doc inside
# _build_context (server-26#80/#95) — the sweep re-links a whole call, not a
# scene, so it passes the call doc's stored (primary-scene) values here. It
# is link-only (create_if_new=False), so a borrowed severity cannot open a
# new incident off this path.
incident_id = await incident_correlator.correlate_call( incident_id = await incident_correlator.correlate_call(
call_id = call_id, call_id = call_id,
node_id = call.get("node_id", ""), node_id = call.get("node_id", ""),
@@ -101,6 +106,8 @@ async def _recorrelate_orphan(call: dict) -> bool:
location = call.get("location"), location = call.get("location"),
location_coords= call.get("location_coords"), location_coords= call.get("location_coords"),
cleared_units = call.get("cleared_units") or [], cleared_units = call.get("cleared_units") or [],
embedding = call.get("embedding"),
severity = call.get("severity"),
reference_time = started_at, # anchor window to when the call happened reference_time = started_at, # anchor window to when the call happened
create_if_new = False, # never create — link-only create_if_new = False, # never create — link-only
) )
+39 -5
View File
@@ -1,7 +1,7 @@
import asyncio import asyncio
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from app.internal.auth import require_admin_token from app.internal.auth import require_admin_token, require_agent_key_or_admin, describe_actor
from app.internal.feature_flags import get_flags, set_flags from app.internal.feature_flags import get_flags, set_flags
from app.internal import firestore as fstore from app.internal import firestore as fstore
from app.config import settings from app.config import settings
@@ -25,20 +25,54 @@ router = APIRouter(prefix="/admin", tags=["admin"])
@router.get("/features") @router.get("/features")
async def get_feature_flags(_=Depends(require_admin_token)): async def get_feature_flags(_=Depends(require_agent_key_or_admin)):
""" """
Return the current AI feature flag state. Admin-only (SAAS_PLAN.md B2c) — Return the current AI feature flag state. Admin-only (SAAS_PLAN.md B2c) —
was previously any authenticated user via require_firebase_token, which was previously any authenticated user via require_firebase_token, which
handed platform-wide AI configuration state to every signed-in viewer handed platform-wide AI configuration state to every signed-in viewer
regardless of org. regardless of org.
Also reachable with the agent service key (server-26#64) so the unattended
runbook can read the switch over HTTP instead of shelling into the
container. Note this is require_agent_key_or_admin, NOT the Discord bot's
service key — see internal/auth.py.
""" """
return await get_flags() return await get_flags()
@router.put("/features") @router.put("/features")
async def update_feature_flags(body: dict, _=Depends(require_admin_token)): async def update_feature_flags(
"""Update one or more AI feature flags. Admin only.""" body: dict,
return await set_flags(body) cascade: bool = Query(
False,
description=(
"Also clear per-system ai_flags overrides for the keys being set, "
"so the flip applies to every radio system."
),
),
principal: dict = Depends(require_agent_key_or_admin),
):
"""Update one or more AI feature flags. Admin or agent service key.
``cascade`` defaults to **False**, deliberately.
The tempting default is True: feature_flags.resolve_flags lets a
system-level False beat a global True, so turning AI back ON globally can
half-apply and leave a system dark, and cascade-by-default would make every
flip total. That reasoning holds only if per-system ai_flags are set
exclusively by hand. They are not — PUT /systems/{system_id}/ai-flags
(routers/systems.py) is a real admin route and drb-frontend's AiFlagsPanel
(app/systems/page.tsx) is a real toggle in the UI. So an override is a
deliberate operator decision that is visible in the interface, and
cascading by default would silently erase it on the next unrelated global
flip, with the operator's own UI still showing what they set until reload.
Silently destroying operator intent is the worse failure, so the caller
says when it means "everywhere": the runbook passes cascade=true on the
shutoff, and the admin UI (which does not pass it) keeps its per-system
overrides.
"""
return await set_flags(body, actor=describe_actor(principal), cascade=cascade)
@router.get("/debug/correlation") @router.get("/debug/correlation")
+1 -1
View File
@@ -97,7 +97,7 @@ async def delete_incident(incident_id: str, _: dict = Depends(require_admin_toke
async def summarize_incident( async def summarize_incident(
incident_id: str, incident_id: str,
background_tasks: BackgroundTasks, background_tasks: BackgroundTasks,
decoded: dict = Depends(require_service_or_firebase_token), decoded: dict = Depends(require_admin_token),
): ):
"""Immediately run the summarizer for a specific incident.""" """Immediately run the summarizer for a specific incident."""
from app.internal.summarizer import _summarize_incident from app.internal.summarizer import _summarize_incident
+7
View File
@@ -114,6 +114,8 @@ async def _correlate_with_consensus(
vehicles: Optional[list] = None, vehicles: Optional[list] = None,
cleared_units: Optional[list] = None, cleared_units: Optional[list] = None,
reassignment: bool = False, reassignment: bool = False,
embedding: Optional[list] = None,
severity: Optional[str] = None,
) -> Optional[str]: ) -> Optional[str]:
""" """
Consensus correlator: runs the rules engine and the cheap LLM in sequence. Consensus correlator: runs the rules engine and the cheap LLM in sequence.
@@ -131,6 +133,7 @@ async def _correlate_with_consensus(
tags=tags, incident_type=incident_type, location=location, tags=tags, incident_type=incident_type, location=location,
location_coords=location_coords, units=units, vehicles=vehicles, location_coords=location_coords, units=units, vehicles=vehicles,
cleared_units=cleared_units, reassignment=reassignment, cleared_units=cleared_units, reassignment=reassignment,
embedding=embedding, severity=severity,
) )
ctx = preview["ctx"] ctx = preview["ctx"]
rules_decision = preview["decision"] rules_decision = preview["decision"]
@@ -221,6 +224,8 @@ async def _run_extraction_pipeline(
vehicles=scene.get("vehicles"), vehicles=scene.get("vehicles"),
cleared_units=scene.get("cleared_units"), cleared_units=scene.get("cleared_units"),
reassignment=is_reassignment, reassignment=is_reassignment,
embedding=scene.get("embedding"),
severity=scene.get("severity"),
) )
if incident_id and incident_id not in incident_ids: if incident_id and incident_id not in incident_ids:
incident_ids.append(incident_id) incident_ids.append(incident_id)
@@ -336,6 +341,8 @@ async def _run_intelligence_pipeline(
vehicles=scene.get("vehicles"), vehicles=scene.get("vehicles"),
cleared_units=scene.get("cleared_units"), cleared_units=scene.get("cleared_units"),
reassignment=is_reassignment, reassignment=is_reassignment,
embedding=scene.get("embedding"),
severity=scene.get("severity"),
) )
if incident_id and incident_id not in incident_ids: if incident_id and incident_id not in incident_ids:
incident_ids.append(incident_id) incident_ids.append(incident_id)
@@ -0,0 +1,296 @@
"""
server-26#64 — a headless, attributable, total AI-flag flip.
Three things are held here:
* ``require_agent_key_or_admin`` is a DISTINCT principal. It takes the agent
service key or a Firebase admin token and refuses the Discord bot's
``service_key``, so an audit entry can name who flipped the switch.
* ``set_flags`` writes an ``audit_log`` entry carrying before/after values,
and an audit failure can neither lose the flag write nor 500 the route.
* ``cascade=True`` clears per-system ``ai_flags`` overrides for the keys
being set, so a flip cannot half-apply — discovered by scanning for
documents that carry the map, never a hardcoded system-id list.
The dependency is exercised directly rather than through TestClient: these are
assertions about the credential check, and routing them through the ASGI stack
would only add ways for the test to pass for the wrong reason.
"""
import pytest
from unittest.mock import AsyncMock, patch
from fastapi import HTTPException
from fastapi.security import HTTPAuthorizationCredentials
from app.config import settings
from app.internal import auth, feature_flags
from app.routers import admin
AGENT_KEY = "agent-key-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
BOT_KEY = "bot-key-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
def _creds(token: str) -> HTTPAuthorizationCredentials:
return HTTPAuthorizationCredentials(scheme="Bearer", credentials=token)
@pytest.fixture
def keys(monkeypatch):
"""Both keys configured and different — the production shape."""
monkeypatch.setattr(settings, "agent_service_key", AGENT_KEY, raising=False)
monkeypatch.setattr(settings, "service_key", BOT_KEY, raising=False)
@pytest.fixture(autouse=True)
def _clear_flag_cache():
"""feature_flags keeps module-level cache state; don't leak it across tests."""
feature_flags._cache = {}
feature_flags._cache_ts = 0.0
yield
feature_flags._cache = {}
feature_flags._cache_ts = 0.0
# ── Item 1: the credential ────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_agent_key_is_accepted_and_identifies_itself(keys):
principal = await auth.require_agent_key_or_admin(_creds(AGENT_KEY))
assert principal["principal"] == "agent"
# The caller must be able to tell the agent from a human admin, or the
# audit entry in item 3 cannot name the actor.
assert auth.describe_actor(principal) == (
auth.AGENT_PRINCIPAL_UID, auth.AGENT_PRINCIPAL_EMAIL,
)
@pytest.mark.asyncio
async def test_discord_bot_service_key_is_rejected(keys):
"""The whole point of a second key: the bot's key must not open this door.
It falls through to the Firebase branch and fails there, so the bot gets a
401 rather than an unattributable flag flip.
"""
with patch.object(auth.firebase_auth, "verify_id_token", side_effect=Exception("not a token")):
with pytest.raises(HTTPException) as exc:
await auth.require_agent_key_or_admin(_creds(BOT_KEY))
assert exc.value.status_code == 401
@pytest.mark.asyncio
async def test_unset_agent_key_cannot_be_bypassed(monkeypatch):
"""An unconfigured key must match nothing — especially not an empty string.
``secrets.compare_digest("", "")`` is a match, so the guard has to be on
the key being configured, not on a ``or ""`` fallback.
"""
monkeypatch.setattr(settings, "agent_service_key", None, raising=False)
with patch.object(auth.firebase_auth, "verify_id_token", side_effect=Exception("not a token")):
for token in ("", " ", "None", "null", AGENT_KEY):
with pytest.raises(HTTPException) as exc:
await auth.require_agent_key_or_admin(_creds(token))
assert exc.value.status_code == 401, token
@pytest.mark.asyncio
async def test_empty_string_agent_key_cannot_be_bypassed(monkeypatch):
"""Same guarantee for a key set to "" by an empty env var."""
monkeypatch.setattr(settings, "agent_service_key", "", raising=False)
with patch.object(auth.firebase_auth, "verify_id_token", side_effect=Exception("not a token")):
with pytest.raises(HTTPException) as exc:
await auth.require_agent_key_or_admin(_creds(""))
assert exc.value.status_code == 401
@pytest.mark.asyncio
async def test_firebase_admin_token_still_works(keys):
decoded = {"uid": "u-1", "email": "admin@example.com", "role": "admin"}
with patch.object(auth.firebase_auth, "verify_id_token", return_value=decoded):
principal = await auth.require_agent_key_or_admin(_creds("firebase-id-token"))
assert principal == decoded
assert auth.describe_actor(principal) == ("u-1", "admin@example.com")
@pytest.mark.asyncio
async def test_non_admin_firebase_token_is_forbidden(keys):
decoded = {"uid": "u-2", "email": "viewer@example.com", "role": "viewer"}
with patch.object(auth.firebase_auth, "verify_id_token", return_value=decoded):
with pytest.raises(HTTPException) as exc:
await auth.require_agent_key_or_admin(_creds("firebase-id-token"))
assert exc.value.status_code == 403
@pytest.mark.asyncio
async def test_missing_credentials_is_401(keys):
with pytest.raises(HTTPException) as exc:
await auth.require_agent_key_or_admin(None)
assert exc.value.status_code == 401
def test_features_routes_use_the_agent_dependency_and_others_do_not():
"""Guards the wiring: only /admin/features moved off require_admin_token."""
def deps(path, method):
for r in admin.router.routes:
if r.path == path and method in r.methods:
return {d.call for d in r.dependant.dependencies}
raise AssertionError(f"no route {method} {path}")
assert auth.require_agent_key_or_admin in deps("/admin/features", "GET")
assert auth.require_agent_key_or_admin in deps("/admin/features", "PUT")
assert auth.require_admin_token in deps("/admin/audit", "GET")
assert auth.require_admin_token in deps("/admin/debug/correlation", "GET")
# ── Items 3 and 4: set_flags audits, and cascades on request ──────────────────
def _fstore_mock(stored: dict, systems: list[dict], updates_sink: list):
"""A Firestore stand-in for feature_flags: one config doc, N system docs."""
mock = AsyncMock()
async def doc_get(collection, doc_id):
return dict(stored) if collection == "config" else None
async def doc_set(collection, doc_id, data, merge=True):
stored.update(data)
async def collection_list(collection, **filters):
return systems if collection == "systems" else []
async def doc_update(collection, doc_id, data):
updates_sink.append((collection, doc_id, data))
mock.doc_get = AsyncMock(side_effect=doc_get)
mock.doc_set = AsyncMock(side_effect=doc_set)
mock.collection_list = AsyncMock(side_effect=collection_list)
mock.doc_update = AsyncMock(side_effect=doc_update)
return mock
def _systems():
return [
# Two systems carry overrides today; the ids are irrelevant to the
# helper and must stay that way.
{"system_id": "sys-a", "ai_flags": {"stt_enabled": False, "correlation_enabled": False}},
{"system_id": "sys-b", "ai_flags": {"stt_enabled": False}},
# Carries the map but not the key being flipped — must be left alone.
{"system_id": "sys-c", "ai_flags": {"summaries_enabled": False}},
# No overrides at all: already inherits, nothing to cascade to.
{"system_id": "sys-d"},
{"system_id": "sys-e", "ai_flags": {}},
]
async def _run_set_flags(updates, *, stored=None, systems=None, cascade=False, actor=None,
audit_side_effect=None):
stored = stored if stored is not None else {"stt_enabled": True, "correlation_enabled": True}
systems = systems if systems is not None else _systems()
updates_sink: list = []
audit_mock = AsyncMock(side_effect=audit_side_effect)
with patch.object(feature_flags, "fstore", _fstore_mock(stored, systems, updates_sink)), \
patch("app.internal.audit.write_audit", new=audit_mock):
result = await feature_flags.set_flags(updates, actor=actor, cascade=cascade)
return result, stored, updates_sink, audit_mock
@pytest.mark.asyncio
async def test_set_flags_is_backward_compatible_without_actor_or_cascade():
"""Existing call shape — set_flags({...}) — must keep working."""
result, stored, updates_sink, audit_mock = await _run_set_flags({"stt_enabled": False})
assert result["stt_enabled"] is False
assert stored["stt_enabled"] is False
assert updates_sink == [] # no cascade unless asked
assert audit_mock.await_count == 1 # but still audited
@pytest.mark.asyncio
async def test_audit_records_before_and_after_and_the_actor():
_, _, _, audit_mock = await _run_set_flags(
{"stt_enabled": False},
actor=(auth.AGENT_PRINCIPAL_UID, auth.AGENT_PRINCIPAL_EMAIL),
)
kwargs = audit_mock.await_args.kwargs
assert kwargs["action"] == "feature_flags.update"
assert kwargs["actor_uid"] == auth.AGENT_PRINCIPAL_UID
assert kwargs["actor_email"] == auth.AGENT_PRINCIPAL_EMAIL
details = kwargs["details"]
assert details["changed"]["stt_enabled"] == {"from": True, "to": False}
assert details["before"]["stt_enabled"] is True
assert details["after"]["stt_enabled"] is False
@pytest.mark.asyncio
async def test_audit_failure_neither_loses_the_write_nor_raises():
"""audit_log is a record OF the write, never a precondition for it."""
result, stored, _, audit_mock = await _run_set_flags(
{"stt_enabled": False},
audit_side_effect=RuntimeError("firestore down"),
)
assert audit_mock.await_count == 1
assert stored["stt_enabled"] is False # flag write survived
assert result["stt_enabled"] is False # and the route returns normally
@pytest.mark.asyncio
async def test_cascade_clears_matching_system_overrides_at_both_levels():
result, stored, updates_sink, audit_mock = await _run_set_flags(
{"stt_enabled": True}, cascade=True,
)
# Global level.
assert stored["stt_enabled"] is True
assert result["stt_enabled"] is True
# System level: only the two documents whose ai_flags carry stt_enabled.
written = {sid: data["ai_flags"] for _, sid, data in updates_sink}
assert set(written) == {"sys-a", "sys-b"}
# The flipped key is removed so the system inherits; unrelated overrides stay.
assert written["sys-a"] == {"correlation_enabled": False}
assert written["sys-b"] == {}
# And the cascade is recorded, per system, in the audit entry.
cascaded = audit_mock.await_args.kwargs["details"]["cascaded_systems"]
assert {c["system_id"] for c in cascaded} == {"sys-a", "sys-b"}
assert cascaded[0]["cleared_overrides"] == {"stt_enabled": False}
@pytest.mark.asyncio
async def test_cascade_finds_systems_by_shape_not_by_hardcoded_id():
"""A newly added system carrying an override must not defeat a flip."""
systems = _systems() + [{"system_id": "sys-new", "ai_flags": {"stt_enabled": False}}]
_, _, updates_sink, _ = await _run_set_flags(
{"stt_enabled": True}, systems=systems, cascade=True,
)
assert "sys-new" in {sid for _, sid, _ in updates_sink}
@pytest.mark.asyncio
async def test_cascade_off_leaves_every_system_override_intact():
"""The default path must not silently erase a deliberate per-system value."""
_, _, updates_sink, _ = await _run_set_flags({"stt_enabled": True}, cascade=False)
assert updates_sink == []
@pytest.mark.asyncio
async def test_cascade_error_on_one_system_does_not_stop_the_others():
systems = _systems()
stored = {"stt_enabled": True, "correlation_enabled": True}
updates_sink: list = []
fs = _fstore_mock(stored, systems, updates_sink)
real_update = fs.doc_update.side_effect
async def flaky(collection, doc_id, data):
if doc_id == "sys-a":
raise RuntimeError("write conflict")
return await real_update(collection, doc_id, data)
fs.doc_update = AsyncMock(side_effect=flaky)
audit_mock = AsyncMock()
with patch.object(feature_flags, "fstore", fs), \
patch("app.internal.audit.write_audit", new=audit_mock):
await feature_flags.set_flags({"stt_enabled": True}, cascade=True)
assert [sid for _, sid, _ in updates_sink] == ["sys-b"]
details = audit_mock.await_args.kwargs["details"]
assert [e["system_id"] for e in details["cascade_errors"]] == ["sys-a"]
@pytest.mark.asyncio
async def test_unrecognised_keys_still_raise():
with pytest.raises(ValueError):
await _run_set_flags({"not_a_flag": True})
+178
View File
@@ -0,0 +1,178 @@
"""
Alert payload redaction — server-26#85.
Board minutes #42 suppress person names on every surface until E&O is bound.
A Discord webhook is the least recoverable surface the system has: once the
text is in a channel we do not own it, cannot unsend it, and cannot audit who
read it. These tests pin the default-closed behaviour so it cannot regress
quietly the way it shipped.
The transcript below deliberately contains a person name; every assertion is
"this string did not leave the process", not "some flag was set".
"""
import pytest
from unittest.mock import AsyncMock, patch
from app.config import settings
from app.internal import alerter
TRANSCRIPT = "Units respond, subject identified as Michael Brennan, 42 Elm Street"
ORG = "org-1"
RULE = {
"rule_id": "r1",
"name": "Structure fire",
"enabled": True,
"keywords": ["respond"],
"discord_webhook": "https://discord.example/webhook",
}
@pytest.fixture
def captured(monkeypatch):
"""Capture what alerter would write to Firestore and POST outbound."""
saved: list[dict] = []
posted: list[dict] = []
async def _doc_set(collection, doc_id, data, merge=False):
saved.append(data)
async def _post(url, json=None, **kwargs):
posted.append(json or {})
class _R:
status_code = 204
return _R()
monkeypatch.setattr(alerter.fstore, "doc_set", _doc_set)
monkeypatch.setattr(
alerter.fstore, "collection_list", AsyncMock(return_value=[dict(RULE)])
)
return saved, posted, _post
async def _run(captured, org_doc):
saved, posted, _post = captured
with patch.object(
alerter.fstore,
"doc_get",
AsyncMock(side_effect=lambda c, i: {"org_id": ORG} if c == "calls" else org_doc),
):
client = AsyncMock()
client.post = _post
with patch("httpx.AsyncClient") as ac:
ac.return_value.__aenter__.return_value = client
await alerter.check_and_dispatch(
call_id="c1",
node_id="n1",
talkgroup_id=1,
talkgroup_name="Fire Dispatch",
tags=[],
transcript=TRANSCRIPT,
)
return saved, posted
def _blob(payloads) -> str:
return " ".join(str(p) for p in payloads)
@pytest.mark.asyncio
async def test_webhook_carries_no_transcript_by_default(captured):
"""The shipped default must not put raw transcript text on the wire."""
saved, posted = await _run(captured, {})
assert posted, "the webhook should still fire — alerting is not disabled, only the text is"
assert "Michael Brennan" not in _blob(posted)
assert "Elm Street" not in _blob(posted)
# The alert is still useful: it names the rule and the talkgroup.
assert "Structure fire" in _blob(posted)
@pytest.mark.asyncio
async def test_alert_event_stores_no_transcript_by_default(captured):
"""Firestore is a surface too — the frontend reads it directly."""
saved, _ = await _run(captured, {})
assert saved, "the alert event should still be recorded"
assert saved[0]["transcript_snippet"] is None
assert "Michael Brennan" not in _blob(saved)
@pytest.mark.asyncio
async def test_org_opt_in_alone_does_not_open_the_gate(captured):
"""
An org owner writing their own org document must not be able to opt
themselves into receiving somebody else's PII. The operator switch is
the control; the org flag is only consent.
"""
assert settings.alert_transcript_snippet_enabled is False
saved, posted = await _run(captured, {"alert_snippet_opt_in": True})
assert "Michael Brennan" not in _blob(posted)
assert "Michael Brennan" not in _blob(saved)
@pytest.mark.asyncio
async def test_both_gates_open_emits_the_snippet(monkeypatch, captured):
"""The opt-in path still works, so this is a gate and not a deletion."""
monkeypatch.setattr(settings, "alert_transcript_snippet_enabled", True)
saved, posted = await _run(captured, {"alert_snippet_opt_in": True})
assert "Michael Brennan" in _blob(posted)
assert saved[0]["transcript_snippet"] is not None
@pytest.mark.asyncio
async def test_operator_switch_alone_does_not_open_the_gate(monkeypatch, captured):
"""Consent is required as well as capability."""
monkeypatch.setattr(settings, "alert_transcript_snippet_enabled", True)
saved, posted = await _run(captured, {})
assert "Michael Brennan" not in _blob(posted)
assert saved[0]["transcript_snippet"] is None
@pytest.mark.asyncio
async def test_unreadable_org_fails_closed(monkeypatch, captured):
"""A Firestore error must withhold the transcript, not default to sending it."""
monkeypatch.setattr(settings, "alert_transcript_snippet_enabled", True)
saved, posted, _post = captured
async def _doc_get(collection, doc_id):
if collection == "calls":
return {"org_id": ORG}
raise RuntimeError("firestore unavailable")
with patch.object(alerter.fstore, "doc_get", _doc_get):
client = AsyncMock()
client.post = _post
with patch("httpx.AsyncClient") as ac:
ac.return_value.__aenter__.return_value = client
await alerter.check_and_dispatch(
call_id="c1", node_id="n1", talkgroup_id=1,
talkgroup_name="Fire Dispatch", tags=[], transcript=TRANSCRIPT,
)
assert "Michael Brennan" not in _blob(posted)
assert saved[0]["transcript_snippet"] is None
@pytest.mark.asyncio
async def test_pre_tenancy_call_with_no_org_fails_closed(monkeypatch, captured):
"""A call with no org_id has nobody who could have consented to anything."""
monkeypatch.setattr(settings, "alert_transcript_snippet_enabled", True)
saved, posted, _post = captured
with patch.object(alerter.fstore, "doc_get", AsyncMock(return_value={})):
client = AsyncMock()
client.post = _post
with patch("httpx.AsyncClient") as ac:
ac.return_value.__aenter__.return_value = client
await alerter.check_and_dispatch(
call_id="c1", node_id="n1", talkgroup_id=1,
talkgroup_name="Fire Dispatch", tags=[], transcript=TRANSCRIPT,
)
assert "Michael Brennan" not in _blob(posted)
assert saved[0]["transcript_snippet"] is None
@@ -229,6 +229,81 @@ async def test_a_bare_number_never_reaches_the_correlator():
assert ctx["location_coords"] is None assert ctx["location_coords"] is None
@pytest.mark.asyncio
async def test_a_scene_with_no_location_does_not_inherit_the_call_docs_pin():
"""
server-26#87. One call can be split into several scenes, and only the
primary scene's geocode is written to the call doc. A non-primary scene
that passes no location of its own must not inherit that pin — doing so
fabricates location_proximity, the strongest accept signal, for a scene
that has none, and drives it into the primary scene's incident.
"""
with patch("app.internal.incident_correlator.fstore") as mock_fstore:
mock_fstore.doc_get = AsyncMock(
return_value={"location_coords": GRASSLANDS}
)
mock_fstore.collection_list = AsyncMock(return_value=[])
ctx = await _build_context(
call_id="call-scene-2", units=None, vehicles=None, cleared_units=None,
location_coords=None, reference_time=NOW,
system_id="sys-1", talkgroup_id=383, talkgroup_name=DISPATCH_TG,
tags=[], incident_type="police", location=None,
reassignment=False, create_if_new=True,
)
assert ctx["coords"] is None
assert ctx["is_thin_call"] is True
@pytest.mark.asyncio
async def test_a_scene_does_not_inherit_the_call_docs_embedding_or_severity():
"""
server-26#80 / #95. Same shape as the #87 coords leak above:
intelligence.py writes only the PRIMARY scene's embedding and severity to
calls/{id}. A non-primary scene being correlated must be judged on its own
embedding (or none) and its own severity — not the call doc's — or a scene
about a different event scores against the wrong incident on the embedding
path and can inherit a minor/moderate/major rung it never had, clearing the
creation gate on borrowed weight.
"""
with patch("app.internal.incident_correlator.fstore") as mock_fstore:
mock_fstore.doc_get = AsyncMock(
return_value={"embedding": [0.1] * 1536, "severity": "major"}
)
mock_fstore.collection_list = AsyncMock(return_value=[])
ctx = await _build_context(
call_id="call-scene-2", units=None, vehicles=None, cleared_units=None,
location_coords=None, reference_time=NOW,
system_id="sys-1", talkgroup_id=383, talkgroup_name=DISPATCH_TG,
tags=[], incident_type="police", location=None,
reassignment=False, create_if_new=True,
embedding=None, severity=None,
)
assert ctx["call_embedding"] is None
assert ctx["call_severity"] == "routine"
assert ctx["is_thin_call"] is True
@pytest.mark.asyncio
async def test_a_scene_is_judged_on_its_own_embedding_and_severity():
"""The other half of #80/#95: the scene's own values are what land in ctx."""
scene_vec = [0.9] * 1536
with patch("app.internal.incident_correlator.fstore") as mock_fstore:
mock_fstore.doc_get = AsyncMock(
return_value={"embedding": [0.1] * 1536, "severity": "routine"}
)
mock_fstore.collection_list = AsyncMock(return_value=[])
ctx = await _build_context(
call_id="call-scene-2", units=None, vehicles=None, cleared_units=None,
location_coords=None, reference_time=NOW,
system_id="sys-1", talkgroup_id=383, talkgroup_name=DISPATCH_TG,
tags=[], incident_type="police", location=None,
reassignment=False, create_if_new=True,
embedding=scene_vec, severity="major",
)
assert ctx["call_embedding"] == scene_vec
assert ctx["call_severity"] == "major"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_a_bare_number_never_becomes_an_incident_location_or_title(): async def test_a_bare_number_never_becomes_an_incident_location_or_title():
inc = await _create(tags=["flames"], location="49", coords=None, inc = await _create(tags=["flames"], location="49", coords=None,
@@ -0,0 +1,52 @@
"""
server-26#81 — any signed-in viewer could trigger OpenAI summary spend.
``POST /incidents/{incident_id}/summarize`` was gated by
``require_service_or_firebase_token``, which accepts ANY authenticated
Firebase user (including role "viewer"), not just admins. Hitting the route
spends OpenAI credits via the background summarizer task. The call-side
equivalent (``PATCH /calls/{id}/transcript``) was already moved to
``require_admin_token``; the incident side was not moved with it.
Following the wiring-test convention in test_admin_feature_flags.py
(``test_features_routes_use_the_agent_dependency_and_others_do_not``): assert
against the route's actual dependant.dependencies rather than round-tripping
through TestClient, so this pins the credential wiring itself and would fail
immediately if someone reverts the dependency back to the weak one.
"""
from app.internal import auth
from app.routers import incidents
def _deps(path: str, method: str) -> set:
for r in incidents.router.routes:
if r.path == path and method in r.methods:
return {d.call for d in r.dependant.dependencies}
raise AssertionError(f"no route {method} {path}")
def test_summarize_incident_requires_admin_not_any_firebase_user():
deps = _deps("/incidents/{incident_id}/summarize", "POST")
assert auth.require_admin_token in deps
assert auth.require_service_or_firebase_token not in deps
def test_read_only_incident_routes_still_accept_any_signed_in_user():
"""Guards against an overcorrection: reads are not spend, they stay open
to any authenticated viewer."""
assert auth.require_service_or_firebase_token in _deps("/incidents", "GET")
assert auth.require_service_or_firebase_token in _deps("/incidents/{incident_id}", "GET")
def test_other_mutating_incident_routes_are_still_admin_only():
"""Unchanged by this fix, but pinned so a future edit can't quietly
loosen them while touching this file."""
for path, method in [
("/incidents/summarize", "POST"),
("/incidents", "POST"),
("/incidents/{incident_id}", "PUT"),
("/incidents/{incident_id}", "DELETE"),
("/incidents/{incident_id}/calls/{call_id}", "POST"),
("/incidents/{incident_id}/calls/{call_id}", "DELETE"),
]:
assert auth.require_admin_token in _deps(path, method), f"{method} {path}"
+16 -1
View File
@@ -3,6 +3,7 @@
import { useState } from "react"; import { useState } from "react";
import { useAuth } from "@/components/AuthProvider"; import { useAuth } from "@/components/AuthProvider";
import { useAlerts } from "@/lib/useAlerts"; import { useAlerts } from "@/lib/useAlerts";
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
import { c2api } from "@/lib/c2api"; import { c2api } from "@/lib/c2api";
import type { AlertRule } from "@/lib/types"; import type { AlertRule } from "@/lib/types";
@@ -185,7 +186,7 @@ function RulesTab({ isAdmin }: { isAdmin: boolean }) {
export default function AlertsPage() { export default function AlertsPage() {
const { isAdmin } = useAuth(); const { isAdmin } = useAuth();
const { alerts, loading } = useAlerts(); const { alerts, loading, error } = useAlerts();
const [tab, setTab] = useState<"events" | "rules">("events"); const [tab, setTab] = useState<"events" | "rules">("events");
async function handleAcknowledge(id: string) { async function handleAcknowledge(id: string) {
@@ -225,9 +226,22 @@ export default function AlertsPage() {
{tab === "events" && ( {tab === "events" && (
loading ? ( loading ? (
<p className="text-gray-500 text-sm font-mono">Loading…</p> <p className="text-gray-500 text-sm font-mono">Loading…</p>
) : error ? (
<p className="text-red-400 text-sm font-mono">
{/requires an index|PERMISSION_DENIED|insufficient permissions/i.test(error)
? "Couldn't load alerts — a database index or security rule isn't deployed on the server yet (server-26 #13 / #51)."
: `Couldn't load alerts: ${error}`}
</p>
) : alerts.length === 0 ? ( ) : alerts.length === 0 ? (
<p className="text-gray-600 text-sm font-mono">No alerts triggered yet.</p> <p className="text-gray-600 text-sm font-mono">No alerts triggered yet.</p>
) : ( ) : (
<div className="space-y-3">
{/* Gate A / A2 (server-26#46) — the Snippet column is transcript text,
and the keyword match that fired the alert was made against it. */}
<MachineOutputNotice
variant="inline"
detail="alerts match against automated transcripts and may fire on, or miss, the wrong words."
/>
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden"> <div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
<table className="w-full text-left"> <table className="w-full text-left">
<thead> <thead>
@@ -280,6 +294,7 @@ export default function AlertsPage() {
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
) )
)} )}
+6
View File
@@ -22,6 +22,7 @@ import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { EmptyState, ErrorBanner } from "@/components/ui/EmptyState"; import { EmptyState, ErrorBanner } from "@/components/ui/EmptyState";
import { SkeletonCard } from "@/components/ui/Skeleton"; import { SkeletonCard } from "@/components/ui/Skeleton";
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
type LinkFilter = "any" | "orphan" | "linked"; type LinkFilter = "any" | "orphan" | "linked";
type TranscriptFilter = "any" | "yes" | "no"; type TranscriptFilter = "any" | "yes" | "no";
@@ -348,6 +349,11 @@ export default function ArchivePage() {
</p> </p>
)} )}
{/* Gate A / A2 (server-26#46) — every row expands to a transcript. */}
<MachineOutputNotice
detail="transcripts and the incident links derived from them are automated output and may contain errors, including misheard names, addresses and unit numbers. Check the recording before acting on them."
/>
{error && <ErrorBanner message={`Couldn't load calls: ${error}`} />} {error && <ErrorBanner message={`Couldn't load calls: ${error}`} />}
{loading && calls.length === 0 ? ( {loading && calls.length === 0 ? (
+29 -5
View File
@@ -1,10 +1,13 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import type { ReactNode } from "react";
import { Badge } from "@/components/ui/Badge"; import { Badge } from "@/components/ui/Badge";
import { LinkButton } from "@/components/ui/Button"; import { LinkButton } from "@/components/ui/Button";
import { UnbuiltMarker } from "@/components/ui/UnbuiltMarker";
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
const FAQS: { q: string; a: string }[] = [ const FAQS: { q: string; a: ReactNode }[] = [
{ {
q: "What hardware do I need to run a node?", q: "What hardware do I need to run a node?",
a: "A node is a small field SDR device running our edge-node software — it needs an SDR dongle capable of receiving your local P25 or analog trunked system, and a network connection to reach your DRB account. Full setup instructions are provided once you add a node.", a: "A node is a small field SDR device running our edge-node software — it needs an SDR dongle capable of receiving your local P25 or analog trunked system, and a network connection to reach your DRB account. Full setup instructions are provided once you add a node.",
@@ -15,7 +18,14 @@ const FAQS: { q: string; a: string }[] = [
}, },
{ {
q: "Does DRB do the transcription and AI work itself, or is that a separate cost?", q: "Does DRB do the transcription and AI work itself, or is that a separate cost?",
a: "Transcription and incident correlation are included in every paid plan and run automatically on every recorded call. The Community plan includes AI features on a limited call volume; Pro and Enterprise scale with your node count.", a: (
<>
Transcription and incident correlation run automatically on every recorded call and are
included — they are not billed as an add-on.
{/* Gate A / A2 (server-26#46) — qualified on the same screen as the claim. */}
<MachineOutputNotice className="mt-3 not-italic" />
</>
),
}, },
{ {
q: "Can I listen to live radio traffic without opening the dashboard?", q: "Can I listen to live radio traffic without opening the dashboard?",
@@ -30,8 +40,22 @@ const FAQS: { q: string; a: string }[] = [
a: "You'll see a plan-limit notice in Settings → Billing before anything is blocked. In this demo build there's no live enforcement wired up yet — see the Billing settings page for what's stubbed vs. real.", a: "You'll see a plan-limit notice in Settings → Billing before anything is blocked. In this demo build there's no live enforcement wired up yet — see the Billing settings page for what's stubbed vs. real.",
}, },
{ {
// Gate A / A1 (server-26#46): plan-tiered retention windows are an unbuilt
// entitlement — there is no TTL and no deletion sweep anywhere in the
// product (server-26#44). The claim is marked unbuilt inline, on this
// screen, rather than quietly dropped.
q: "How long is call and incident history kept?", q: "How long is call and incident history kept?",
a: "Retention depends on plan — 7 days on Community, 90 days on Pro, and a year or more on Enterprise (negotiable). Historical calls remain searchable and linked to their incidents for the full retention window.", a: (
<>
<UnbuiltMarker>Retention limits — not yet available</UnbuiltMarker>
<p className="mt-2">
Today nothing is deleted automatically: calls, recordings and incidents stay searchable and
linked to their incidents for as long as your account is open. Per-plan retention windows and
automatic deletion are not built yet, so we make no commitment about how long anything is kept
or when it goes away. If you need data removed, ask us and we will remove it by hand.
</p>
</>
),
}, },
{ {
q: "Is DMR supported?", q: "Is DMR supported?",
@@ -66,7 +90,7 @@ export default function FaqPage() {
{FAQS.map((item, i) => { {FAQS.map((item, i) => {
const open = openIndex === i; const open = openIndex === i;
return ( return (
<div key={item.q}> <div key={i}>
<button <button
onClick={() => setOpenIndex(open ? null : i)} onClick={() => setOpenIndex(open ? null : i)}
className="w-full flex items-center justify-between gap-4 py-5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 rounded-lg" className="w-full flex items-center justify-between gap-4 py-5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 rounded-lg"
@@ -76,7 +100,7 @@ export default function FaqPage() {
<ChevronIcon open={open} /> <ChevronIcon open={open} />
</button> </button>
{open && ( {open && (
<p className="text-gray-400 text-sm leading-relaxed pb-5 pr-8 animate-fade-in">{item.a}</p> <div className="text-gray-400 text-sm leading-relaxed pb-5 pr-8 animate-fade-in">{item.a}</div>
)} )}
</div> </div>
); );
+14 -1
View File
@@ -1,8 +1,16 @@
import { Card } from "@/components/ui/Card"; import { Card } from "@/components/ui/Card";
import { Badge } from "@/components/ui/Badge"; import { Badge } from "@/components/ui/Badge";
import { LinkButton } from "@/components/ui/Button"; import { LinkButton } from "@/components/ui/Button";
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
const SECTIONS = [ const SECTIONS: {
eyebrow: string;
title: string;
body: string;
points: string[];
/** Section describes AI pipeline output — render the Gate A / A2 qualifier. */
qualify?: boolean;
}[] = [
{ {
eyebrow: "Correlation", eyebrow: "Correlation",
title: "Calls become incidents", title: "Calls become incidents",
@@ -13,6 +21,7 @@ const SECTIONS = [
"Distance, timing, shared units, and talkgroup signals all feed the match", "Distance, timing, shared units, and talkgroup signals all feed the match",
"Every call keeps its correlation debug trail for admins to audit", "Every call keeps its correlation debug trail for admins to audit",
], ],
qualify: true,
}, },
{ {
eyebrow: "AI pipeline", eyebrow: "AI pipeline",
@@ -24,6 +33,7 @@ const SECTIONS = [
"Scene & entity extraction feeds the correlator and the incident summary", "Scene & entity extraction feeds the correlator and the incident summary",
"AI-generated incident summaries, regenerable on demand", "AI-generated incident summaries, regenerable on demand",
], ],
qualify: true,
}, },
{ {
eyebrow: "Situational awareness", eyebrow: "Situational awareness",
@@ -89,6 +99,9 @@ export default function FeaturesPage() {
</li> </li>
))} ))}
</ul> </ul>
{/* Gate A / A2 (server-26#46) — the sections that describe the AI
pipeline carry the same qualifier the product surfaces do. */}
{s.qualify && <MachineOutputNotice className="mt-5" />}
</Card> </Card>
</div> </div>
))} ))}
+14
View File
@@ -163,6 +163,20 @@ html:not(.dark) .border-indigo-800 { border-color: #a5b4fc !important; }
animation: pulse-ring 1.8s ease-out infinite; animation: pulse-ring 1.8s ease-out infinite;
} }
/* ── Leaflet stacking fix ─────────────────────────────────────────────────────
* Leaflet's internal panes (z-index 200–700) and its zoom / layers controls
* (z-index 1000) otherwise paint above the sticky app Nav (z-40) and any modal
* overlay — on Live this put the account dropdown *behind* the map. Pinning the
* map container to its own low stacking context keeps Leaflet's internal layer
* order intact while dropping the whole map (tiles + controls) below the app
* chrome. The map's own overlay UI (legend, incident rail, clock, fit-all) sits
* outside .leaflet-container, so it is unaffected and still renders on top.
*/
.leaflet-container {
position: relative;
z-index: 0;
}
/* ── Form inputs ─────────────────────────────────────────────────────────── */ /* ── Form inputs ─────────────────────────────────────────────────────────── */
html:not(.dark) input:not([type="submit"]):not([type="button"]):not([type="reset"]), html:not(.dark) input:not([type="submit"]):not([type="button"]):not([type="reset"]),
html:not(.dark) select, html:not(.dark) select,
+11 -3
View File
@@ -12,6 +12,7 @@ import { TypeGlyph } from "@/components/marks/TypeGlyph";
import { SeverityMark } from "@/components/marks/SeverityMark"; import { SeverityMark } from "@/components/marks/SeverityMark";
import { isKnownSeverity } from "@/lib/severity"; import { isKnownSeverity } from "@/lib/severity";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
import type { CallRecord } from "@/lib/types"; import type { CallRecord } from "@/lib/types";
const MapView = dynamic(() => import("@/components/MapView"), { ssr: false }); const MapView = dynamic(() => import("@/components/MapView"), { ssr: false });
@@ -99,6 +100,7 @@ export default function IncidentDetailPage() {
const displayTags = incident.tags.filter((t) => t !== "auto-generated"); const displayTags = incident.tags.filter((t) => t !== "auto-generated");
const unitsActive = incident.units_active ?? incident.units ?? []; const unitsActive = incident.units_active ?? incident.units ?? [];
const unitsCleared = incident.units_cleared ?? []; const unitsCleared = incident.units_cleared ?? [];
const vehicles = incident.vehicles ?? [];
const active = incident.status === "active"; const active = incident.status === "active";
const visible = newestFirst.slice(0, earlierShown); const visible = newestFirst.slice(0, earlierShown);
@@ -165,7 +167,7 @@ export default function IncidentDetailPage() {
)} )}
{/* Summary — first, in prose. Not a tab. */} {/* Summary — first, in prose. Not a tab. */}
<div> <div className="space-y-2.5">
{incident.summary ? ( {incident.summary ? (
<p className="text-[16.5px] text-ink leading-[1.58]">{incident.summary}</p> <p className="text-[16.5px] text-ink leading-[1.58]">{incident.summary}</p>
) : ( ) : (
@@ -178,6 +180,10 @@ export default function IncidentDetailPage() {
)} )}
</p> </p>
)} )}
{/* Gate A / A2 (server-26#46): the summary, the title, the location,
the units and the vehicles below are ALL pipeline output, so the
notice sits on this screen with them — not on a policy page. */}
<MachineOutputNotice />
</div> </div>
{/* On scene / Cleared */} {/* On scene / Cleared */}
@@ -208,11 +214,11 @@ export default function IncidentDetailPage() {
</div> </div>
</div> </div>
{incident.vehicles?.length > 0 && ( {vehicles.length > 0 && (
<div> <div>
<p className="text-xs text-ink-muted uppercase tracking-wide mb-2">Vehicles</p> <p className="text-xs text-ink-muted uppercase tracking-wide mb-2">Vehicles</p>
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{incident.vehicles.map((v) => ( {vehicles.map((v) => (
<span key={v} className="text-xs bg-raised text-ink-2 px-2 py-0.5 rounded font-mono">{v}</span> <span key={v} className="text-xs bg-raised text-ink-2 px-2 py-0.5 rounded font-mono">{v}</span>
))} ))}
</div> </div>
@@ -225,6 +231,8 @@ export default function IncidentDetailPage() {
<p className="text-xs text-ink-muted uppercase tracking-wide mb-1"> <p className="text-xs text-ink-muted uppercase tracking-wide mb-1">
Calls ({calls.length}) Calls ({calls.length})
</p> </p>
{/* Gate A / A2 — the spine renders transcripts. */}
{calls.length > 0 && <MachineOutputNotice variant="inline" className="mb-2" />}
{callsLoading ? ( {callsLoading ? (
<p className="text-ink-muted text-sm">Loading…</p> <p className="text-ink-muted text-sm">Loading…</p>
) : calls.length === 0 ? ( ) : calls.length === 0 ? (
+17 -1
View File
@@ -12,6 +12,7 @@ import { Button } from "@/components/ui/Button";
import { Badge } from "@/components/ui/Badge"; import { Badge } from "@/components/ui/Badge";
import { EmptyState, ErrorBanner } from "@/components/ui/EmptyState"; import { EmptyState, ErrorBanner } from "@/components/ui/EmptyState";
import { SkeletonCard } from "@/components/ui/Skeleton"; import { SkeletonCard } from "@/components/ui/Skeleton";
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
import { isKnownSeverity, severityRank } from "@/lib/severity"; import { isKnownSeverity, severityRank } from "@/lib/severity";
import { SeverityMark, SeveritySpine } from "@/components/marks/SeverityMark"; import { SeverityMark, SeveritySpine } from "@/components/marks/SeverityMark";
import { TypeGlyph } from "@/components/marks/TypeGlyph"; import { TypeGlyph } from "@/components/marks/TypeGlyph";
@@ -27,6 +28,17 @@ const FILTER_THRESHOLD: Record<SeverityFilter, number> = { all: -1, minor: 1, mo
type SortMode = "recent" | "severity"; type SortMode = "recent" | "severity";
// The Firestore client surfaces a missing composite index or an undeployed
// ruleset as a raw multi-line string with a console URL in it — not something
// to put in front of an operator. Collapse the known infra failures to a plain
// line; pass anything else straight through so a real bug still shows.
function friendlyIncidentsError(raw: string): string {
if (/requires an index|PERMISSION_DENIED|Missing or insufficient permissions|failed-precondition/i.test(raw)) {
return "Couldn't load incidents — the incidents database index isn't deployed on the server yet. This is a one-time backend deploy step (server-26 #13 / #51), not a problem with your data.";
}
return `Couldn't load incidents: ${raw}`;
}
function fmtTime(iso: string) { function fmtTime(iso: string) {
try { return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); } catch { return iso; } try { return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); } catch { return iso; }
} }
@@ -219,6 +231,10 @@ export default function IncidentsPage() {
action={isAdmin && <Button onClick={() => setShowCreate(true)}>+ Create Incident</Button>} action={isAdmin && <Button onClick={() => setShowCreate(true)}>+ Create Incident</Button>}
/> />
{/* Gate A / A2 (server-26#46) — every row's title, location and unit
chips are pipeline output, so the notice rides with the list. */}
<MachineOutputNotice variant="inline" />
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-1 bg-surface border border-line rounded-lg p-1 w-fit"> <div className="flex flex-wrap gap-1 bg-surface border border-line rounded-lg p-1 w-fit">
{SEVERITY_FILTERS.map(({ key, label }) => ( {SEVERITY_FILTERS.map(({ key, label }) => (
@@ -281,7 +297,7 @@ export default function IncidentsPage() {
recorded yet" over the top of it told the operator the radio was recorded yet" over the top of it told the operator the radio was
quiet when the page had simply failed to load — server-26#13. */} quiet when the page had simply failed to load — server-26#13. */}
{filtered.length === 0 && error && ( {filtered.length === 0 && error && (
<ErrorBanner message={`Couldn't load incidents: ${error}`} /> <ErrorBanner message={friendlyIncidentsError(error)} />
)} )}
{filtered.length === 0 && !error && ( {filtered.length === 0 && !error && (
+4 -1
View File
@@ -9,6 +9,7 @@ import { useCalls } from "@/lib/useCalls";
import { StatusBadge } from "@/components/StatusBadge"; import { StatusBadge } from "@/components/StatusBadge";
import { NodeConfigModal } from "@/components/NodeConfigModal"; import { NodeConfigModal } from "@/components/NodeConfigModal";
import { CallRow } from "@/components/CallRow"; import { CallRow } from "@/components/CallRow";
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
import { useAuth } from "@/components/AuthProvider"; import { useAuth } from "@/components/AuthProvider";
import { c2api } from "@/lib/c2api"; import { c2api } from "@/lib/c2api";
import type { NodeRecord } from "@/lib/types"; import type { NodeRecord } from "@/lib/types";
@@ -59,7 +60,7 @@ function DiscordJoinModal({
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
<form <form
onSubmit={handleSubmit} onSubmit={handleSubmit}
className="bg-gray-900 border border-gray-700 rounded-xl p-6 space-y-4 font-mono w-full max-w-sm" className="bg-gray-900 border border-gray-700 rounded-xl p-6 space-y-4 font-mono w-full max-w-sm max-h-[90vh] overflow-y-auto"
> >
<h3 className="text-white font-semibold">Join Discord Voice</h3> <h3 className="text-white font-semibold">Join Discord Voice</h3>
<div> <div>
@@ -335,6 +336,8 @@ export default function NodeDetailPage() {
{/* Recent calls */} {/* Recent calls */}
<section> <section>
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Recent Calls</h2> <h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Recent Calls</h2>
{/* Gate A / A2 (server-26#46) — each row expands to a transcript. */}
{nodeCalls.length > 0 && <MachineOutputNotice variant="inline" className="mb-3" />}
{nodeCalls.length === 0 ? ( {nodeCalls.length === 0 ? (
<p className="text-gray-600 text-sm font-mono">No calls recorded from this node.</p> <p className="text-gray-600 text-sm font-mono">No calls recorded from this node.</p>
) : ( ) : (
+1 -1
View File
@@ -42,7 +42,7 @@ export default function NodesPage() {
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{pending.map((n) => ( {pending.map((n) => (
<div key={n.node_id} onClick={() => setConfigNode(n)} className="cursor-pointer"> <div key={n.node_id} onClick={() => setConfigNode(n)} className="cursor-pointer">
<NodeCard node={n} system={systemMap[n.assigned_system_id ?? ""]} /> <NodeCard node={n} system={systemMap[n.assigned_system_id ?? ""]} linkToDetail={false} />
</div> </div>
))} ))}
</div> </div>
+1 -1
View File
@@ -43,7 +43,7 @@ export default function OnboardingPage() {
// Firebase custom claims only show up in a *freshly fetched* ID token — // Firebase custom claims only show up in a *freshly fetched* ID token —
// getIdTokenResult(true) inside refreshClaims forces that fetch, then // getIdTokenResult(true) inside refreshClaims forces that fetch, then
// AuthProvider's own state (orgId) updates and the effect above // AuthProvider's own state (orgId) updates and the effect above
// redirects to /dashboard. // redirects to "/" (Live).
await refreshClaims(); await refreshClaims();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Could not set up your organization. Try again."); setError(err instanceof Error ? err.message : "Could not set up your organization. Try again.");
@@ -10,6 +10,7 @@ import { Card, CardHeader } from "@/components/ui/Card";
import { Badge } from "@/components/ui/Badge"; import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { SkeletonCard } from "@/components/ui/Skeleton"; import { SkeletonCard } from "@/components/ui/Skeleton";
import { UnbuiltMarker } from "@/components/ui/UnbuiltMarker";
function fmtDate(iso: string) { function fmtDate(iso: string) {
return new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }); return new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
@@ -68,6 +69,19 @@ function UsageBar({ label, used, limit }: { label: string; used: number; limit:
); );
} }
/**
* Gate A / A1 (server-26#46). The plan cards below render taglines that claim
* entitlements with no backend behind them. Those claims get marked unbuilt
* inline, on this screen, next to the plan that makes them. This is a labelling
* change only — it does not build any of these, and it must never grow into a
* price or a checkout path (Gate B still bars charging anyone).
*/
const UNBUILT_CLAIMS: Partial<Record<PlanId, string[]>> = {
free: ["Retention window"],
pro: ["Retention window"],
enterprise: ["Custom retention", "SSO / SAML", "Uptime SLA", "Data residency"],
};
const INVOICE_TONE: Record<Invoice["status"], "success" | "warning" | "neutral" | "danger"> = { const INVOICE_TONE: Record<Invoice["status"], "success" | "warning" | "neutral" | "danger"> = {
paid: "success", paid: "success",
open: "warning", open: "warning",
@@ -169,6 +183,13 @@ export default function BillingSettingsPage() {
> >
<p className="text-white font-semibold text-sm">{p.name}</p> <p className="text-white font-semibold text-sm">{p.name}</p>
<p className="text-gray-500 text-xs mt-1 flex-1">{p.tagline}</p> <p className="text-gray-500 text-xs mt-1 flex-1">{p.tagline}</p>
{(UNBUILT_CLAIMS[p.id] ?? []).length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{(UNBUILT_CLAIMS[p.id] ?? []).map((claim) => (
<UnbuiltMarker key={claim}>{claim} — not yet available</UnbuiltMarker>
))}
</div>
)}
<p className="text-white text-lg font-bold font-mono mt-3"> <p className="text-white text-lg font-bold font-mono mt-3">
{p.priceMonthlyUsd === null ? "Custom" : p.priceMonthlyUsd === 0 ? "Free" : `$${p.priceMonthlyUsd}/mo`} {p.priceMonthlyUsd === null ? "Custom" : p.priceMonthlyUsd === 0 ? "Free" : `$${p.priceMonthlyUsd}/mo`}
</p> </p>
+61 -4
View File
@@ -39,6 +39,30 @@ function EnrollmentTokensPanel() {
const [label, setLabel] = useState(""); const [label, setLabel] = useState("");
const [minting, setMinting] = useState(false); const [minting, setMinting] = useState(false);
const [justMinted, setJustMinted] = useState<string | null>(null); const [justMinted, setJustMinted] = useState<string | null>(null);
const [cmdCopied, setCmdCopied] = useState(false);
const [tokenCopied, setTokenCopied] = useState(false);
// The label the operator typed for the token that was just minted — used as
// the node id in the install command below. Captured on mint because `label`
// itself is cleared afterward.
const [mintedLabel, setMintedLabel] = useState<string | null>(null);
// The paste-ready one-shot install command for a fresh Pi. The node id comes
// from the label just entered (spaces → dashes; install.sh requires
// [A-Za-z0-9_-]); if that yields nothing it falls back to a node-XXX
// placeholder. The MQTT broker host is the documented mqtt.<domain> sibling
// of the api host (install.sh header) — a DNS assumption the operator checks.
const c2Url = (process.env.NEXT_PUBLIC_C2_URL ?? "https://api.example.net").replace(/\/$/, "");
const mqttBroker = (() => {
try { return `mqtt.${new URL(c2Url).hostname.replace(/^api\./, "")}`; }
catch { return "mqtt.example.net"; }
})();
const nodeIdForCmd =
(mintedLabel ?? "").trim().replace(/\s+/g, "-").replace(/[^A-Za-z0-9_-]/g, "") || "node-XXX";
const installCmd = justMinted
? `curl -fsSL https://git.vpn.cusano.net/logan/node-26/raw/tag/v1/install.sh \\
| sudo bash -s -- --token ${justMinted} --node-id ${nodeIdForCmd} \\
--c2-url ${c2Url} --mqtt-broker ${mqttBroker}`
: "";
const load = useCallback(() => { const load = useCallback(() => {
c2api.listEnrollmentTokens() c2api.listEnrollmentTokens()
@@ -57,6 +81,7 @@ function EnrollmentTokensPanel() {
try { try {
const result = await c2api.mintEnrollmentToken(label.trim()); const result = await c2api.mintEnrollmentToken(label.trim());
setJustMinted(result.token); setJustMinted(result.token);
setMintedLabel(label.trim());
setLabel(""); setLabel("");
load(); load();
} catch (err) { } catch (err) {
@@ -87,11 +112,43 @@ function EnrollmentTokensPanel() {
<p className="text-xs text-indigo-200 font-mono mb-1"> <p className="text-xs text-indigo-200 font-mono mb-1">
New token — copy it now, it won&apos;t be shown again: New token — copy it now, it won&apos;t be shown again:
</p> </p>
<p className="text-xs text-indigo-100 font-mono break-all bg-gray-900 rounded px-2 py-1.5">{justMinted}</p>
<div className="flex items-start gap-2">
<p className="flex-1 text-xs text-indigo-100 font-mono break-all bg-gray-900 rounded px-2 py-1.5">{justMinted}</p>
<button <button
type="button" type="button"
onClick={() => setJustMinted(null)} onClick={() => navigator.clipboard?.writeText(justMinted).then(() => {
className="text-xs text-indigo-300 hover:text-indigo-200 mt-2 transition-colors" setTokenCopied(true); setTimeout(() => setTokenCopied(false), 2000);
})}
className="text-xs text-indigo-300 hover:text-indigo-200 px-2 py-1.5 transition-colors shrink-0"
>
{tokenCopied ? "Copied" : "Copy"}
</button>
</div>
<p className="text-xs text-indigo-200 font-mono mt-3 mb-1">
…or run this on a fresh Pi{" "}
{nodeIdForCmd === "node-XXX"
? <>(edit <span className="text-indigo-100">node-XXX</span> and check the broker host)</>
: <>(check the broker host)</>}:
</p>
<div className="flex items-start gap-2">
<pre className="flex-1 text-xs text-indigo-100 font-mono whitespace-pre-wrap break-all bg-gray-900 rounded px-2 py-1.5">{installCmd}</pre>
<button
type="button"
onClick={() => navigator.clipboard?.writeText(installCmd).then(() => {
setCmdCopied(true); setTimeout(() => setCmdCopied(false), 2000);
})}
className="text-xs text-indigo-300 hover:text-indigo-200 px-2 py-1.5 transition-colors shrink-0"
>
{cmdCopied ? "Copied" : "Copy"}
</button>
</div>
<button
type="button"
onClick={() => { setJustMinted(null); setMintedLabel(null); }}
className="text-xs text-indigo-300 hover:text-indigo-200 mt-3 transition-colors"
> >
Dismiss Dismiss
</button> </button>
@@ -105,7 +162,7 @@ function EnrollmentTokensPanel() {
<input <input
value={label} value={label}
onChange={(e) => setLabel(e.target.value)} onChange={(e) => setLabel(e.target.value)}
placeholder="Label, e.g. 'node-003 field kit'" placeholder="Node ID, e.g. node-003"
className="flex-1 min-w-[12rem] bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm focus:outline-none focus:border-indigo-500" className="flex-1 min-w-[12rem] bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm focus:outline-none focus:border-indigo-500"
/> />
<Button type="submit" size="sm" disabled={minting || !label.trim()}> <Button type="submit" size="sm" disabled={minting || !label.trim()}>
+5
View File
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import { useSystems } from "@/lib/useSystems"; import { useSystems } from "@/lib/useSystems";
import { c2api } from "@/lib/c2api"; import { c2api } from "@/lib/c2api";
import { useAuth } from "@/components/AuthProvider"; import { useAuth } from "@/components/AuthProvider";
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
import type { import type {
AreaContext, AreaContext,
LocalKnowledgeEntry, LocalKnowledgeEntry,
@@ -1223,7 +1224,11 @@ function SourceCallPlayer({ callId }: { callId: string }) {
<p className="text-gray-600 italic">No audio</p> <p className="text-gray-600 italic">No audio</p>
)} )}
{transcript && ( {transcript && (
<>
<p className="text-gray-500 italic line-clamp-2">{transcript}</p> <p className="text-gray-500 italic line-clamp-2">{transcript}</p>
{/* Gate A / A2 (server-26#46) — this is a raw pipeline transcript. */}
<MachineOutputNotice variant="inline" className="text-[10px]" />
</>
)} )}
</div> </div>
)} )}
+5 -3
View File
@@ -22,7 +22,9 @@ function TripCard({ trip, isAdmin, onDelete }: {
}) { }) {
const router = useRouter(); const router = useRouter();
const today = new Date().toISOString().slice(0, 10); const today = new Date().toISOString().slice(0, 10);
const upcoming = trip.start_date >= today; // Bucket and badge must agree: the list groups on end_date (page.tsx ~L176),
// so a trip isn't "Past" until it's over, not when it starts.
const upcoming = trip.end_date >= today;
const attendeeCount = Object.keys(trip.attendees ?? {}).length; const attendeeCount = Object.keys(trip.attendees ?? {}).length;
return ( return (
@@ -97,10 +99,10 @@ function CreateModal({ onClose, onCreate }: {
} }
return ( return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50"> <div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
<form <form
onSubmit={handleSubmit} onSubmit={handleSubmit}
className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md space-y-4" className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md space-y-4 max-h-[90vh] overflow-y-auto"
> >
<h2 className="text-white font-bold">New Trip</h2> <h2 className="text-white font-bold">New Trip</h2>
+2 -4
View File
@@ -23,7 +23,7 @@ function fmtClock(s: number): string {
return `${m}:${r.toString().padStart(2, "0")}`; return `${m}:${r.toString().padStart(2, "0")}`;
} }
function InlinePlayer({ callId, hasAudio }: { callId: string; hasAudio: boolean }) { function InlinePlayer({ callId }: { callId: string }) {
const [url, setUrl] = useState<string | null>(null); const [url, setUrl] = useState<string | null>(null);
const [error, setError] = useState(false); const [error, setError] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -32,8 +32,6 @@ function InlinePlayer({ callId, hasAudio }: { callId: string; hasAudio: boolean
const [duration, setDuration] = useState(0); const [duration, setDuration] = useState(0);
const audioRef = useRef<HTMLAudioElement | null>(null); const audioRef = useRef<HTMLAudioElement | null>(null);
if (!hasAudio) return null;
async function ensureUrl() { async function ensureUrl() {
if (url || loading) return; if (url || loading) return;
setLoading(true); setLoading(true);
@@ -169,7 +167,7 @@ export function CallSpineEntry({
{hasAudio && ( {hasAudio && (
<div className="mt-1.5"> <div className="mt-1.5">
<InlinePlayer callId={call.call_id} hasAudio={hasAudio} /> <InlinePlayer callId={call.call_id} />
</div> </div>
)} )}
+31 -4
View File
@@ -14,6 +14,7 @@ import {
import L from "leaflet"; import L from "leaflet";
import type { CallRecord, IncidentRecord, NodeRecord, NodeStatus } from "@/lib/types"; import type { CallRecord, IncidentRecord, NodeRecord, NodeStatus } from "@/lib/types";
import { isKnownSeverity, SEVERITY_COLORS, SEVERITY_LABEL, type Severity } from "@/lib/severity"; import { isKnownSeverity, SEVERITY_COLORS, SEVERITY_LABEL, type Severity } from "@/lib/severity";
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
// ── Leaflet icon fix ────────────────────────────────────────────────────────── // ── Leaflet icon fix ──────────────────────────────────────────────────────────
delete (L.Icon.Default.prototype as unknown as Record<string, unknown>)._getIconUrl; delete (L.Icon.Default.prototype as unknown as Record<string, unknown>)._getIconUrl;
@@ -23,6 +24,17 @@ L.Icon.Default.mergeOptions({
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png", shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
}); });
// ── Basemap tiles ─────────────────────────────────────────────────────────────
// Default is CARTO's keyless dark raster basemap — no token, fits the dark UI.
// Overridable via NEXT_PUBLIC_MAP_TILE_URL so a keyed style (a CARTO account
// style, MapTiler, Mapbox, …) can be dropped in for prod without a code change.
// Whatever is supplied must use Leaflet's {s}/{z}/{x}/{y}{r} placeholder scheme.
const MAP_TILE_URL =
process.env.NEXT_PUBLIC_MAP_TILE_URL ||
"https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png";
const MAP_TILE_ATTRIBUTION =
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/">CARTO</a>';
// ── Colour ──────────────────────────────────────────────────────────────────── // ── Colour ────────────────────────────────────────────────────────────────────
// Severity is the only hue on this map — see UI_REDESIGN.md §2.3. Incident // Severity is the only hue on this map — see UI_REDESIGN.md §2.3. Incident
// type is carried by the glyph knocked out of the pin, never by colour, and // type is carried by the glyph knocked out of the pin, never by colour, and
@@ -351,6 +363,8 @@ function FanIncidentLayer({
</div> </div>
); );
})} })}
{/* Gate A / A2 (server-26#46) — same screen as the output. */}
<MachineOutputNotice variant="popup" />
</div> </div>
</Popup> </Popup>
</Marker> </Marker>
@@ -402,6 +416,9 @@ function IncidentPathLayer({
<a href={`/incidents/${inc.incident_id}`} className="text-xs text-blue-600 hover:underline block mt-1"> <a href={`/incidents/${inc.incident_id}`} className="text-xs text-blue-600 hover:underline block mt-1">
View incident → View incident →
</a> </a>
{/* Gate A / A2 (server-26#46) — the stop location and its
ordering come from the transcript, not from GPS. */}
<MachineOutputNotice variant="popup" />
</div> </div>
</Popup> </Popup>
</Marker> </Marker>
@@ -534,14 +551,14 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
{/* Base layers */} {/* Base layers */}
<LayersControl.BaseLayer checked name="Dark"> <LayersControl.BaseLayer checked name="Dark">
<TileLayer <TileLayer
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png" url={MAP_TILE_URL}
attribution='&copy; <a href="https://carto.com/">CARTO</a>' attribution={MAP_TILE_ATTRIBUTION}
/> />
</LayersControl.BaseLayer> </LayersControl.BaseLayer>
<LayersControl.BaseLayer name="Light"> <LayersControl.BaseLayer name="Light">
<TileLayer <TileLayer
url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png" url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png"
attribution='&copy; <a href="https://carto.com/">CARTO</a>' attribution={MAP_TILE_ATTRIBUTION}
/> />
</LayersControl.BaseLayer> </LayersControl.BaseLayer>
<LayersControl.BaseLayer name="Streets"> <LayersControl.BaseLayer name="Streets">
@@ -657,7 +674,14 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
{incidents.length > 0 && ( {incidents.length > 0 && (
<> <>
{/* Desktop: left sidebar — starts below zoom controls + fit-all button */} {/* Desktop: left sidebar — starts below zoom controls + fit-all button */}
<div className="absolute top-[8rem] left-3 bottom-[4.5rem] z-[1001] hidden md:flex flex-col w-56 gap-1.5 overflow-y-auto"> <div className="absolute top-[8rem] left-3 bottom-[4.5rem] z-[1001] hidden md:flex flex-col w-56 gap-1.5">
{/* Gate A / A2 (server-26#46) — the rail's titles, locations and
unit counts are pipeline output. Pinned above the scroll area
so it cannot be scrolled off the screen it qualifies. */}
<div className="bg-surface/90 backdrop-blur-sm border border-line rounded-lg px-2 py-1.5 shrink-0">
<MachineOutputNotice variant="inline" className="text-[10px] leading-snug items-start" />
</div>
<div className="flex flex-col gap-1.5 overflow-y-auto">
{incidents.map((inc) => { {incidents.map((inc) => {
const color = severityColor(inc.severity); const color = severityColor(inc.severity);
const age = inc.started_at ? timeAgo(new Date(inc.started_at)) : null; const age = inc.started_at ? timeAgo(new Date(inc.started_at)) : null;
@@ -712,6 +736,7 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
); );
})} })}
</div> </div>
</div>
{/* Mobile: bottom drawer */} {/* Mobile: bottom drawer */}
<div className="absolute bottom-0 left-0 right-0 z-[1001] md:hidden"> <div className="absolute bottom-0 left-0 right-0 z-[1001] md:hidden">
@@ -724,6 +749,8 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
</button> </button>
{drawerOpen && ( {drawerOpen && (
<div className="bg-surface/95 border-t border-line max-h-52 overflow-y-auto px-3 py-2 space-y-1.5"> <div className="bg-surface/95 border-t border-line max-h-52 overflow-y-auto px-3 py-2 space-y-1.5">
{/* Gate A / A2 (server-26#46) */}
<MachineOutputNotice variant="inline" className="text-[10px] items-start" />
{incidents.map((inc) => { {incidents.map((inc) => {
const color = severityColor(inc.severity); const color = severityColor(inc.severity);
const label = ( const label = (
+10 -4
View File
@@ -5,15 +5,20 @@ import type { NodeRecord, SystemRecord } from "@/lib/types";
interface Props { interface Props {
node: NodeRecord; node: NodeRecord;
system?: SystemRecord; system?: SystemRecord;
/**
* When false, the card renders without its `/nodes/[id]` Link wrapper so a
* parent click handler can take the interaction (pending nodes open the
* config modal instead of navigating). Defaults to true.
*/
linkToDetail?: boolean;
} }
export function NodeCard({ node, system }: Props) { export function NodeCard({ node, system, linkToDetail = true }: Props) {
const lastSeen = node.last_seen const lastSeen = node.last_seen
? new Date(node.last_seen).toLocaleTimeString() ? new Date(node.last_seen).toLocaleTimeString()
: "never"; : "never";
return ( const body = (
<Link href={`/nodes/${node.node_id}`}>
<div className="bg-gray-900 border border-gray-800 rounded-lg p-4 hover:border-gray-600 transition-colors cursor-pointer"> <div className="bg-gray-900 border border-gray-800 rounded-lg p-4 hover:border-gray-600 transition-colors cursor-pointer">
<div className="flex items-start justify-between mb-3"> <div className="flex items-start justify-between mb-3">
<div> <div>
@@ -58,6 +63,7 @@ export function NodeCard({ node, system }: Props) {
</div> </div>
)} )}
</div> </div>
</Link>
); );
return linkToDetail ? <Link href={`/nodes/${node.node_id}`}>{body}</Link> : body;
} }
+2 -2
View File
@@ -50,8 +50,8 @@ export function NodeConfigModal({ node, systems, onClose }: Props) {
const selectedPreset = PRESETS.find((p) => p.value === preset); const selectedPreset = PRESETS.find((p) => p.value === preset);
return ( return (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50"> <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
<div className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md font-mono"> <div className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md font-mono max-h-[90vh] overflow-y-auto">
<h2 className="text-white font-semibold mb-1">Configure Node</h2> <h2 className="text-white font-semibold mb-1">Configure Node</h2>
<p className="text-gray-400 text-sm mb-5"> <p className="text-gray-400 text-sm mb-5">
<span className="text-indigo-400">{node.node_id}</span> connected for the first time. <span className="text-indigo-400">{node.node_id}</span> connected for the first time.
@@ -0,0 +1,112 @@
import type { ReactNode } from "react";
/**
* Gate A, condition A2 (board minutes #42 / #79, tracked at server-26#46).
*
* Transcripts, incident summaries, titles, locations and extracted entities are
* all produced by the AI pipeline (transcription -> scene/entity extraction ->
* correlation -> summary). None of it is reviewed by a human before a reader
* sees it, and entity-name accuracy has never been measured (server-26#48).
*
* Gate A therefore requires that machine-generated content is labelled as
* machine-generated and unverified ON THE SAME SCREEN as the content itself —
* a note on another page does not satisfy the condition. This is the single
* element that does that; render it beside every AI-derived surface.
*
* Copy rules (do not "improve" these away):
* - the words "machine-generated" and "unverified" must both appear
* - it must not promise accuracy
* - it must not name a model or a vendor
* - it must not state or imply a price
*
* Variants exist only because the surfaces differ in space, not because the
* claim differs. All three say the same thing.
* block — full-width strip above/below a body of AI output (default)
* inline — one compact line, for dense list headers and table captions
* popup — for a Leaflet popup, which is stock-white in BOTH themes, so it
* uses fixed grays instead of the ink/surface tokens
*/
type Variant = "block" | "inline" | "popup";
function InfoGlyph({ className }: { className?: string }) {
return (
<svg
width="13"
height="13"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
aria-hidden="true"
className={className}
>
<circle cx="8" cy="8" r="6.5" />
<path d="M8 7.25v4" />
<path d="M8 4.75h.01" />
</svg>
);
}
const LEAD = "Machine-generated and unverified";
interface Props {
variant?: Variant;
/** Overrides the trailing sentence. The lead ("Machine-generated and unverified") is fixed. */
detail?: ReactNode;
className?: string;
}
export function MachineOutputNotice({ variant = "block", detail, className }: Props) {
const body =
detail ??
"transcripts, summaries and extracted details are automated output and may contain errors. Check the recording before acting on them.";
const shortBody = detail ?? "automated output, may contain errors.";
if (variant === "popup") {
// Leaflet popups render on a white wrapper regardless of theme, so this
// deliberately does not use the ink/surface tokens.
return (
<p
role="note"
className={["text-[10px] leading-snug text-gray-500 mt-1.5 pt-1.5 border-t border-gray-200", className ?? ""]
.filter(Boolean)
.join(" ")}
>
{LEAD} — {shortBody}
</p>
);
}
if (variant === "inline") {
return (
<p
role="note"
className={["flex items-center gap-1.5 text-xs text-ink-muted", className ?? ""].filter(Boolean).join(" ")}
>
<InfoGlyph className="shrink-0" />
<span>
<span className="text-ink-2 font-medium">{LEAD}</span> — {shortBody}
</span>
</p>
);
}
return (
<div
role="note"
className={[
"flex items-start gap-2 rounded-lg border border-line bg-raised/60 px-3 py-2",
className ?? "",
]
.filter(Boolean)
.join(" ")}
>
<InfoGlyph className="mt-0.5 shrink-0 text-ink-muted" />
<p className="text-xs leading-relaxed text-ink-muted">
<span className="text-ink-2 font-medium">{LEAD}</span> — {body}
</p>
</div>
);
}
@@ -0,0 +1,38 @@
import type { ReactNode } from "react";
/**
* Gate A, condition A1 (board minutes #42, tracked at server-26#46).
*
* Gate A blocks putting an unbuilt entitlement claim in front of a reader
* without saying, inline and on the same screen, that it is not built. Known
* unbuilt claims today:
* - retention windows (7 / 90 / 365 days) — no TTL and no deletion sweep
* exists anywhere in the product (server-26#44, DEFERRED.md)
* - Enterprise SSO / SAML — no backend at all
* - uptime SLA — none offered or measured
* - custom data residency — no backend at all
*
* This marks the claim. It does NOT build the feature, and nothing here may
* grow into a price or a checkout path (Gate B still bars charging anyone).
*/
export function UnbuiltMarker({
children = "Not yet available",
className,
}: {
children?: ReactNode;
className?: string;
}) {
return (
<span
className={[
"inline-flex items-center whitespace-nowrap rounded-full border border-line-strong",
"px-1.5 py-0.5 align-middle text-[10px] font-medium uppercase tracking-wide text-ink-muted",
className ?? "",
]
.filter(Boolean)
.join(" ")}
>
{children}
</span>
);
}
+3 -2
View File
@@ -143,8 +143,9 @@ export interface IncidentRecord {
call_ids: string[]; call_ids: string[];
system_ids: string[]; system_ids: string[];
talkgroup_ids: string[]; talkgroup_ids: string[];
units: string[]; /** Omitted on incident docs written before these fields existed. */
vehicles: string[]; units?: string[];
vehicles?: string[];
/** Units currently believed on scene — maintained by incident_correlator.py `_attach`. */ /** Units currently believed on scene — maintained by incident_correlator.py `_attach`. */
units_active?: string[]; units_active?: string[];
/** Units that reported clearing/back in service on this incident. */ /** Units that reported clearing/back in service on this incident. */
@@ -30,6 +30,14 @@ GEMINI_API_KEY={{ vault_gemini_api_key }}
SERVICE_KEY={{ vault_service_key }} SERVICE_KEY={{ vault_service_key }}
ENROLLMENT_TOKEN={{ vault_enrollment_token }} ENROLLMENT_TOKEN={{ vault_enrollment_token }}
# Agent/automation key for the unattended work session's headless routes
# (GET/PUT /admin/features). MUST NOT equal vault_service_key: that one is the
# Discord bot's, and one shared value would make the bot and the agent the same
# unattributable principal in audit_log (server-26#64). default('') so a vault
# that predates this key still templates instead of failing the play; blank
# just leaves the agent path closed.
AGENT_SERVICE_KEY={{ vault_agent_service_key | default('') }}
# Bare domain, not app.<domain>: the frontend is served on {{ domain }} itself # Bare domain, not app.<domain>: the frontend is served on {{ domain }} itself
# (see Caddyfile.j2 — only api. and the bare name have DNS records). This said # (see Caddyfile.j2 — only api. and the bare name have DNS records). This said
# app.{{ domain }} while the browser origin was https://{{ domain }}, so every # app.{{ domain }} while the browser origin was https://{{ domain }}, so every
+5 -1
View File
@@ -22,7 +22,11 @@ vault_mqtt_c2_pass: "CHANGE_ME"
vault_mqtt_dynsec_admin_pass: "CHANGE_ME" # openssl rand -hex 32 — must be >=12 chars, plugin-enforced minimum vault_mqtt_dynsec_admin_pass: "CHANGE_ME" # openssl rand -hex 32 — must be >=12 chars, plugin-enforced minimum
# ── C2 Core ─────────────────────────────────────────────────────────────────── # ── C2 Core ───────────────────────────────────────────────────────────────────
vault_service_key: "" # openssl rand -hex 32 vault_service_key: "" # openssl rand -hex 32 — the Discord bot's key
# The work-session agent's own key for GET/PUT /admin/features. Generate a
# SEPARATE value — never a copy of vault_service_key, or a flag flip cannot be
# attributed to the agent vs the bot (server-26#64).
vault_agent_service_key: "" # openssl rand -hex 32
vault_enrollment_token: "" # openssl rand -hex 32 — fleet-wide, shared by every node's POST /nodes/enroll vault_enrollment_token: "" # openssl rand -hex 32 — fleet-wide, shared by every node's POST /nodes/enroll
vault_openai_api_key: "" vault_openai_api_key: ""
vault_google_maps_api_key: "" vault_google_maps_api_key: ""