diff --git a/drb-frontend/app/alerts/page.tsx b/drb-frontend/app/alerts/page.tsx index 2bf0156..58a3ddf 100644 --- a/drb-frontend/app/alerts/page.tsx +++ b/drb-frontend/app/alerts/page.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { useAuth } from "@/components/AuthProvider"; import { useAlerts } from "@/lib/useAlerts"; +import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice"; import { c2api } from "@/lib/c2api"; import type { AlertRule } from "@/lib/types"; @@ -228,6 +229,13 @@ export default function AlertsPage() { ) : alerts.length === 0 ? (

No alerts triggered yet.

) : ( +
+ {/* Gate A / A2 (server-26#46) — the Snippet column is transcript text, + and the keyword match that fired the alert was made against it. */} +
@@ -280,6 +288,7 @@ export default function AlertsPage() {
+
) )} diff --git a/drb-frontend/app/calls/page.tsx b/drb-frontend/app/calls/page.tsx index 96de10a..29c528e 100644 --- a/drb-frontend/app/calls/page.tsx +++ b/drb-frontend/app/calls/page.tsx @@ -22,6 +22,7 @@ import { Badge } from "@/components/ui/Badge"; import { Button } from "@/components/ui/Button"; import { EmptyState, ErrorBanner } from "@/components/ui/EmptyState"; import { SkeletonCard } from "@/components/ui/Skeleton"; +import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice"; type LinkFilter = "any" | "orphan" | "linked"; type TranscriptFilter = "any" | "yes" | "no"; @@ -348,6 +349,11 @@ export default function ArchivePage() {

)} + {/* Gate A / A2 (server-26#46) — every row expands to a transcript. */} + + {error && } {loading && calls.length === 0 ? ( diff --git a/drb-frontend/app/faq/page.tsx b/drb-frontend/app/faq/page.tsx index f6eb4a0..e4c2a66 100644 --- a/drb-frontend/app/faq/page.tsx +++ b/drb-frontend/app/faq/page.tsx @@ -1,10 +1,13 @@ "use client"; import { useState } from "react"; +import type { ReactNode } from "react"; import { Badge } from "@/components/ui/Badge"; 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?", 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?", - 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. */} + + + ), }, { 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.", }, { + // 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?", - 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: ( + <> + Retention limits — not yet available +

+ 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. +

+ + ), }, { q: "Is DMR supported?", @@ -66,7 +90,7 @@ export default function FaqPage() { {FAQS.map((item, i) => { const open = openIndex === i; return ( -
+
{open && ( -

{item.a}

+
{item.a}
)}
); diff --git a/drb-frontend/app/features/page.tsx b/drb-frontend/app/features/page.tsx index 446fd11..b1457b7 100644 --- a/drb-frontend/app/features/page.tsx +++ b/drb-frontend/app/features/page.tsx @@ -1,8 +1,16 @@ import { Card } from "@/components/ui/Card"; import { Badge } from "@/components/ui/Badge"; 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", title: "Calls become incidents", @@ -13,6 +21,7 @@ const SECTIONS = [ "Distance, timing, shared units, and talkgroup signals all feed the match", "Every call keeps its correlation debug trail for admins to audit", ], + qualify: true, }, { eyebrow: "AI pipeline", @@ -24,6 +33,7 @@ const SECTIONS = [ "Scene & entity extraction feeds the correlator and the incident summary", "AI-generated incident summaries, regenerable on demand", ], + qualify: true, }, { eyebrow: "Situational awareness", @@ -89,6 +99,9 @@ export default function FeaturesPage() { ))} + {/* Gate A / A2 (server-26#46) — the sections that describe the AI + pipeline carry the same qualifier the product surfaces do. */} + {s.qualify && }
))} diff --git a/drb-frontend/app/incidents/[id]/page.tsx b/drb-frontend/app/incidents/[id]/page.tsx index 8ec2a5c..19638c7 100644 --- a/drb-frontend/app/incidents/[id]/page.tsx +++ b/drb-frontend/app/incidents/[id]/page.tsx @@ -12,6 +12,7 @@ import { TypeGlyph } from "@/components/marks/TypeGlyph"; import { SeverityMark } from "@/components/marks/SeverityMark"; import { isKnownSeverity } from "@/lib/severity"; import { Button } from "@/components/ui/Button"; +import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice"; import type { CallRecord } from "@/lib/types"; const MapView = dynamic(() => import("@/components/MapView"), { ssr: false }); @@ -165,7 +166,7 @@ export default function IncidentDetailPage() { )} {/* Summary — first, in prose. Not a tab. */} -
+
{incident.summary ? (

{incident.summary}

) : ( @@ -178,6 +179,10 @@ export default function IncidentDetailPage() { )}

)} + {/* 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. */} +
{/* On scene / Cleared */} @@ -225,6 +230,8 @@ export default function IncidentDetailPage() {

Calls ({calls.length})

+ {/* Gate A / A2 — the spine renders transcripts. */} + {calls.length > 0 && } {callsLoading ? (

Loading…

) : calls.length === 0 ? ( diff --git a/drb-frontend/app/incidents/page.tsx b/drb-frontend/app/incidents/page.tsx index 7908013..b96de0f 100644 --- a/drb-frontend/app/incidents/page.tsx +++ b/drb-frontend/app/incidents/page.tsx @@ -12,6 +12,7 @@ import { Button } from "@/components/ui/Button"; import { Badge } from "@/components/ui/Badge"; import { EmptyState, ErrorBanner } from "@/components/ui/EmptyState"; import { SkeletonCard } from "@/components/ui/Skeleton"; +import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice"; import { isKnownSeverity, severityRank } from "@/lib/severity"; import { SeverityMark, SeveritySpine } from "@/components/marks/SeverityMark"; import { TypeGlyph } from "@/components/marks/TypeGlyph"; @@ -219,6 +220,10 @@ export default function IncidentsPage() { action={isAdmin && } /> + {/* Gate A / A2 (server-26#46) — every row's title, location and unit + chips are pipeline output, so the notice rides with the list. */} + +
{SEVERITY_FILTERS.map(({ key, label }) => ( diff --git a/drb-frontend/app/nodes/[id]/page.tsx b/drb-frontend/app/nodes/[id]/page.tsx index 0e1a740..0298663 100644 --- a/drb-frontend/app/nodes/[id]/page.tsx +++ b/drb-frontend/app/nodes/[id]/page.tsx @@ -9,6 +9,7 @@ import { useCalls } from "@/lib/useCalls"; import { StatusBadge } from "@/components/StatusBadge"; import { NodeConfigModal } from "@/components/NodeConfigModal"; import { CallRow } from "@/components/CallRow"; +import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice"; import { useAuth } from "@/components/AuthProvider"; import { c2api } from "@/lib/c2api"; import type { NodeRecord } from "@/lib/types"; @@ -335,6 +336,8 @@ export default function NodeDetailPage() { {/* Recent calls */}

Recent Calls

+ {/* Gate A / A2 (server-26#46) — each row expands to a transcript. */} + {nodeCalls.length > 0 && } {nodeCalls.length === 0 ? (

No calls recorded from this node.

) : ( diff --git a/drb-frontend/app/settings/billing/page.tsx b/drb-frontend/app/settings/billing/page.tsx index 292172e..00e4d3b 100644 --- a/drb-frontend/app/settings/billing/page.tsx +++ b/drb-frontend/app/settings/billing/page.tsx @@ -10,6 +10,7 @@ import { Card, CardHeader } from "@/components/ui/Card"; import { Badge } from "@/components/ui/Badge"; import { Button } from "@/components/ui/Button"; import { SkeletonCard } from "@/components/ui/Skeleton"; +import { UnbuiltMarker } from "@/components/ui/UnbuiltMarker"; function fmtDate(iso: string) { 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> = { + free: ["Retention window"], + pro: ["Retention window"], + enterprise: ["Custom retention", "SSO / SAML", "Uptime SLA", "Data residency"], +}; + const INVOICE_TONE: Record = { paid: "success", open: "warning", @@ -169,6 +183,13 @@ export default function BillingSettingsPage() { >

{p.name}

{p.tagline}

+ {(UNBUILT_CLAIMS[p.id] ?? []).length > 0 && ( +
+ {(UNBUILT_CLAIMS[p.id] ?? []).map((claim) => ( + {claim} — not yet available + ))} +
+ )}

{p.priceMonthlyUsd === null ? "Custom" : p.priceMonthlyUsd === 0 ? "Free" : `$${p.priceMonthlyUsd}/mo`}

diff --git a/drb-frontend/app/systems/page.tsx b/drb-frontend/app/systems/page.tsx index afe9473..5be6c59 100644 --- a/drb-frontend/app/systems/page.tsx +++ b/drb-frontend/app/systems/page.tsx @@ -5,6 +5,7 @@ import { useRouter } from "next/navigation"; import { useSystems } from "@/lib/useSystems"; import { c2api } from "@/lib/c2api"; import { useAuth } from "@/components/AuthProvider"; +import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice"; import type { AreaContext, LocalKnowledgeEntry, @@ -1223,7 +1224,11 @@ function SourceCallPlayer({ callId }: { callId: string }) {

No audio

)} {transcript && ( -

{transcript}

+ <> +

{transcript}

+ {/* Gate A / A2 (server-26#46) — this is a raw pipeline transcript. */} + + )}
)} diff --git a/drb-frontend/components/MapView.tsx b/drb-frontend/components/MapView.tsx index 0f4d242..b047e68 100644 --- a/drb-frontend/components/MapView.tsx +++ b/drb-frontend/components/MapView.tsx @@ -14,6 +14,7 @@ import { import L from "leaflet"; import type { CallRecord, IncidentRecord, NodeRecord, NodeStatus } from "@/lib/types"; import { isKnownSeverity, SEVERITY_COLORS, SEVERITY_LABEL, type Severity } from "@/lib/severity"; +import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice"; // ── Leaflet icon fix ────────────────────────────────────────────────────────── delete (L.Icon.Default.prototype as unknown as Record)._getIconUrl; @@ -351,6 +352,8 @@ function FanIncidentLayer({
); })} + {/* Gate A / A2 (server-26#46) — same screen as the output. */} +
@@ -402,6 +405,9 @@ function IncidentPathLayer({ View incident → + {/* Gate A / A2 (server-26#46) — the stop location and its + ordering come from the transcript, not from GPS. */} + @@ -657,7 +663,14 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = [] {incidents.length > 0 && ( <> {/* Desktop: left sidebar — starts below zoom controls + fit-all button */} -
+
+ {/* 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. */} +
+ +
+
{incidents.map((inc) => { const color = severityColor(inc.severity); const age = inc.started_at ? timeAgo(new Date(inc.started_at)) : null; @@ -711,6 +724,7 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = [] ); })} +
{/* Mobile: bottom drawer */} @@ -724,6 +738,8 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = [] {drawerOpen && (
+ {/* Gate A / A2 (server-26#46) */} + {incidents.map((inc) => { const color = severityColor(inc.severity); const label = ( diff --git a/drb-frontend/components/ui/MachineOutputNotice.tsx b/drb-frontend/components/ui/MachineOutputNotice.tsx new file mode 100644 index 0000000..8df8962 --- /dev/null +++ b/drb-frontend/components/ui/MachineOutputNotice.tsx @@ -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 ( + + ); +} + +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 ( +

+ {LEAD} — {shortBody} +

+ ); + } + + if (variant === "inline") { + return ( +

+ + + {LEAD} — {shortBody} + +

+ ); + } + + return ( +
+ +

+ {LEAD} — {body} +

+
+ ); +} diff --git a/drb-frontend/components/ui/UnbuiltMarker.tsx b/drb-frontend/components/ui/UnbuiltMarker.tsx new file mode 100644 index 0000000..613d775 --- /dev/null +++ b/drb-frontend/components/ui/UnbuiltMarker.tsx @@ -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 ( + + {children} + + ); +}