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.
+
);
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.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
+ ))}
+
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. */}
+
+ {/* 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 (
+
+ );
+}
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}
+
+ );
+}