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>
This commit is contained in:
Logan Cusano
2026-08-31 02:47:40 -04:00
co-authored by Claude Opus 5
parent bdb57ae75a
commit b7222230bd
12 changed files with 268 additions and 9 deletions
+9
View File
@@ -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 ? (
<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">
<table className="w-full text-left">
<thead>
@@ -280,6 +288,7 @@ export default function AlertsPage() {
</tbody>
</table>
</div>
</div>
)
)}
+6
View File
@@ -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() {
</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}`} />}
{loading && calls.length === 0 ? (
+29 -5
View File
@@ -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. */}
<MachineOutputNotice className="mt-3 not-italic" />
</>
),
},
{
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: (
<>
<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?",
@@ -66,7 +90,7 @@ export default function FaqPage() {
{FAQS.map((item, i) => {
const open = openIndex === i;
return (
<div key={item.q}>
<div key={i}>
<button
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"
@@ -76,7 +100,7 @@ export default function FaqPage() {
<ChevronIcon open={open} />
</button>
{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>
);
+14 -1
View File
@@ -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() {
</li>
))}
</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>
</div>
))}
+8 -1
View File
@@ -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. */}
<div>
<div className="space-y-2.5">
{incident.summary ? (
<p className="text-[16.5px] text-ink leading-[1.58]">{incident.summary}</p>
) : (
@@ -178,6 +179,10 @@ export default function IncidentDetailPage() {
)}
</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>
{/* On scene / Cleared */}
@@ -225,6 +230,8 @@ export default function IncidentDetailPage() {
<p className="text-xs text-ink-muted uppercase tracking-wide mb-1">
Calls ({calls.length})
</p>
{/* Gate A / A2 — the spine renders transcripts. */}
{calls.length > 0 && <MachineOutputNotice variant="inline" className="mb-2" />}
{callsLoading ? (
<p className="text-ink-muted text-sm">Loading…</p>
) : calls.length === 0 ? (
+5
View File
@@ -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 && <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 gap-1 bg-surface border border-line rounded-lg p-1 w-fit">
{SEVERITY_FILTERS.map(({ key, label }) => (
+3
View File
@@ -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 */}
<section>
<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 ? (
<p className="text-gray-600 text-sm font-mono">No calls recorded from this node.</p>
) : (
@@ -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<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"> = {
paid: "success",
open: "warning",
@@ -169,6 +183,13 @@ export default function BillingSettingsPage() {
>
<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>
{(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.priceMonthlyUsd === null ? "Custom" : p.priceMonthlyUsd === 0 ? "Free" : `$${p.priceMonthlyUsd}/mo`}
</p>
+6 -1
View File
@@ -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 }) {
<p className="text-gray-600 italic">No audio</p>
)}
{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>
)}
+17 -1
View File
@@ -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<string, unknown>)._getIconUrl;
@@ -351,6 +352,8 @@ function FanIncidentLayer({
</div>
);
})}
{/* Gate A / A2 (server-26#46) — same screen as the output. */}
<MachineOutputNotice variant="popup" />
</div>
</Popup>
</Marker>
@@ -402,6 +405,9 @@ function IncidentPathLayer({
<a href={`/incidents/${inc.incident_id}`} className="text-xs text-blue-600 hover:underline block mt-1">
View incident →
</a>
{/* Gate A / A2 (server-26#46) — the stop location and its
ordering come from the transcript, not from GPS. */}
<MachineOutputNotice variant="popup" />
</div>
</Popup>
</Marker>
@@ -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 */}
<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) => {
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 = []
</a>
);
})}
</div>
</div>
{/* Mobile: bottom drawer */}
@@ -724,6 +738,8 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
</button>
{drawerOpen && (
<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) => {
const color = severityColor(inc.severity);
const label = (
@@ -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>
);
}