Kairos — Frontend Reference

Next.js 16 · React 19 · TypeScript · Tailwind CSS 4 · Docker

Local: http://localhost:3000 — served by kairos-frontend container.

Design system: tokens and shared classes live in frontend/src/app/globals.css; primitives in components/ui.tsx + ui-card.tsx. The workspace follows the landing page's language (square chrome, Instrument display type, hairline .mesh grids, lp-card/lp-cell hovers); the why and the rules are in implementation/ui-overhaul.md. Filled danger blocks take text-on-danger, never text-white (dark's red is light). Landing shots: the product screenshots on the landing page are frontend/public/shots/*.png, regenerated by ./tools/capture_landing_shots.sh against the running stack (it signs in as the seeded admin with the password from .env). An entry can carry a small script run before the shot (the RCA shot runs an example investigation, the graph shot scrolls the canvas into view), because an empty form is not a product shot. Re-run it after any UI or data change. Landing demo video: paste a YouTube link into DEMO_YOUTUBE_URL (app/page.tsx) and the #demo player section, the header's Demo cell and the hero CTA's in-page scroll all switch on; empty means none of it renders and the CTA leaves for DEMO_FALLBACK_URL. The player is a facade — YouTube is contacted only on press, via youtube-nocookie. URL parsing is lib/youtube.ts (tested). PageHeader takes no eyebrow (the orange area chips were removed 2026-10-04): the title is Title Case and names the page, the lede says what it is for, and per-record pages put the record id at the start of the lede. PageHeader's compact is for per-record views (/assets/[id], /documents/[id]) only — a top-level page keeps the full 40px title. Filled actions use Button/ButtonLink (they carry the .fill-sweep hover); never hand-roll a bg-accent button. Icons come only from components/icon.tsx (Phosphor Regular, inlined, MIT); never hand-draw an <svg> icon. Read both before building any new UI component.


1. Stack

ConcernChoice
FrameworkNext.js 16 (App Router)
UIReact 19, Tailwind CSS 4, custom design tokens
LanguageTypeScript (strict)
AuthSupabase-backed via POST /auth/login
API (browser)fetch against NEXT_PUBLIC_API_URL (default http://localhost:8000)
API (SSR)fetch against API_INTERNAL_URL (http://kairos-backend-api:8000 in Docker)
Containernode:20-alpine; npm ci at build time; src/ + public/ hot-reloaded via volume mount

2. Folder Structure

frontend/
├── public/
│   ├── logo.png                    # Brand logo — served at /logo.png; favicon source
│   ├── shots/                       # Landing-page product screenshots (real captures of the app)
│   └── sw.js                        # Service worker (PWA offline shell, per-user API cache) — registered PROD-ONLY
├── src/
│   ├── app/
│   │   ├── icon.jpeg                # Auto-favicon (Next.js App Router convention)
│   │   ├── layout.tsx               # Root layout — globals, font, theme, favicon metadata
│   │   ├── page.tsx                 # PUBLIC LANDING PAGE (marketing surface, no auth)
│   │   ├── landing-fonts.ts         # Instrument Sans + DM Sans, landing-only (next/font)
│   │   ├── globals.css              # Design tokens (app: --accent/--ink/…; landing: --lp-* scoped)
│   │   ├── not-found.tsx            # On-theme 404 page
│   │   ├── login/page.tsx           # Login screen (real auth → POST /auth/login)
│   │   └── (app)/                   # Authenticated route group — wrapped in <AppShell>
│   │       ├── layout.tsx · loading.tsx · error.tsx   # shell · skeleton · error boundary
│   │       ├── briefs/{page,[id]/page}                # inbox · detail+ack+feedback
│   │       ├── copilot/page                           # knowledge copilot (phase-gated synthesis)
│   │       ├── assets/{page,[id]/page,bootstrap/page} # list · detail · MDM identity confirm
│   │       ├── rca/page                               # RCA pack generator
│   │       ├── compliance/{page,audit-pack,nonconformance}
│   │       ├── governance/{page,conflicts,quarantine,moc,moc/[id],sla,circuit-breaker,model-gate}
│   │       ├── documents/{page,[id]/page,[id]/topology,compare,ingest}
│   │       ├── events/{page,[id]/page}                # operational event surfaces
│   │       ├── projects/page                          # engineering + procurement registry
│   │       ├── graph/page · audit/page                # temporal graph · audit trail
│   │       ├── management/{page,cross-site,plant-state}
│   │       ├── field/{deviation,elicitation/[workOrderId],voice,voice/[workOrderId]}  # mobile field capture
│   │       └── (desktop)/offboarding/{page,[sessionId]/page}   # retiring-expert knowledge transfer
│   ├── components/
│   │   ├── app-shell.tsx            # Desktop sidebar + mobile drawer + auth guard + sign-out
│   │   ├── lazy.tsx                 # "use client" wrapper holding dynamic(ssr:false) imports (Next 16 rule)
│   │   ├── blast-radius-panel.tsx   # React Flow mini-graph of nodes a document/change affects
│   │   ├── knowledge-graph.tsx      # React Flow temporal asset graph (Layer 4)
│   │   ├── supersede-action.tsx     # Document supersede form (client, router.refresh on success)
│   │   ├── voice-recorder.tsx       # Mic capture → Blob (MediaRecorder), used by field voice + copilot
│   │   ├── command-palette.tsx      # ⌘K search palette
│   │   ├── icon.tsx                 # THE icon set — Phosphor Regular path data, inlined (MIT); `*-fill` cuts for active nav
│   │   ├── brand-link.tsx           # Logo link component
│   │   ├── mobile-app-header.tsx    # Mobile top bar (menu · search · + Ingest · account)
│   │   ├── error-boundary.tsx       # Client error boundary wrapper
│   │   ├── brief-card.tsx · brief-inbox.tsx · brief-detail.tsx   # brief inbox pieces
│   │   ├── charts.tsx · charts/     # Chart primitives and chart sub-components
│   │   ├── stat-pills.tsx           # Compact metric pills
│   │   ├── system-tabs.tsx          # Sub-nav tabs for system surfaces (Health · Settings)
│   │   ├── theme-toggle.tsx · skeleton.tsx
│   │   ├── use-role.ts              # useRole() + ADMIN_ROLES / PROMOTE_ROLES / RESOLVE_ROLES / FIELD_ROLES
│   │   ├── ui.tsx                   # Primitives: PageHeader, KpiCard/KpiGroup, DataTable, FilterTabs, StatusBadge,
│   │   │                             # AuthorityBadge, SourceChip, Button, Modal, Timeline, EmptyState, RefusalCard
│   │   └── ui-card.tsx              # Card primitives (lp-card, lp-cell hovers)
│   └── lib/
│       ├── api.ts                   # All fetch helpers — SSR-aware API_BASE, live-only fetchers, response normalizers
│       ├── auth.ts                  # login(), getMe(), logout() — Supabase token lifecycle (kairos-token key)
│       ├── types.ts                 # All API-derived TypeScript types (single source of truth)
│       ├── use-fetch.ts             # useFetch() — loading / live / error+retry state machine
│       ├── idb.ts                   # IndexedDB offline write-queue (OfflineQueue) — flushed on reconnect
│       ├── utils.ts · format.ts · motion.ts · labels.ts · user-initials.ts · search-shortcut.ts · graph-theme.tsx
│       └── copilot.ts · rca.ts      # live types + real constants (SUGGESTIONS, RCA_PRESETS); rca.ts `rcaFor` is TEST-ONLY
├── Dockerfile                       # node:20-alpine; NEXT_TELEMETRY_DISABLED=1; npm ci at build
└── package.json

3. Routes

RoutePageStatus
/Public landing page (no auth)Live
/loginEmail + password loginLive (real auth)
/docs, /docs/[...slug]Public documentation: an explicit allowlist of files from the repository's docs/ folder (NAV in lib/docs.ts: architecture, diagrams, datasets, API, backend, frontend, databases, fixtures, tests, benchmarks), read at build time and rendered to React elements by lib/markdown.tsx (marked parses; nothing is injected as HTML). A file not in NAV is not published: the deployment runbook, infrastructure, Docker, status log, code review and presentation script stay private. redact() strips seeded login addresses, live hostnames, development default credentials, IPs and cloud ids before rendering, and lib/docs.test.ts fails if a published document still carries one. Needs ../docs at build: Vercel must include source files outside the root directory, and the dev compose mounts ./docs:/docsLive (public, no auth)
/briefsBrief inboxLive
/briefs/[id]Brief detail + ack + feedbackLive
/copilotKnowledge Q&A chat + answer rating + pending-MoC bannerLive
/assetsAsset list — search, class/criticality filters, open work orders + compliance gaps per assetLive
/assets/[id]Asset detail + aliases + knowledge + hierarchy + asset-scoped searchLive
/rcaRCA pack generatorLive
/complianceCompliance gaps + audit readinessLive
/governanceHub → all 8 governance surfacesLive (hub page)
/governance/conflictsConflict list + resolveLive
/governance/conflicts/[id]Conflict detail — both sources, authority, SLA, blast radius, resolve / MoCLive
/governance/quarantineQuarantine list + promote/dispute/request-infoLive (role-gated)
/governance/mocManagement of Change queueLive
/governance/moc/[id]MoC detail (sources joined from linked conflict) + engineer/admin sign-offLive (GET/POST /governance/moc/{id} + /approve)
/governance/slaSLA report — overdue conflicts + quarantineLive
/governance/timestamp-driftCross-system clock drift on compound events (report-only)Live
/governance/push-volume-gateEEMUA 191 briefs-per-operator-hour gate, 7/30/90-day window (report-only)Live
/governance/circuit-breakerSPC circuit-breaker state by asset classLive
/governance/model-gateModel gate — P/R/F1 history + validation corpus. Run is a ~2.5-min async task: button shows a queued banner, polls history, auto-refreshesLive (Run = admin)
/compliance/audit-packAudit-evidence pack by clause + human sign-offLive
/compliance/nonconformanceNon-conformances (conflicts + failed inspections + disputes)Composed from existing endpoints
/documentsDocument registry, 100 a page (?page=N, a pager under the table); the pills count the whole vaultLive
/documents/[id]Document detail + supersede chain + extraction results + PII-redacted export; a PDF offers Extract form fields (engineer, reliability, admin), which sends a form's fields to the review queue; a document held by the OCR gate says so, and reliability/admin release or reject it thereLive
/documents/[id]/topologyP&ID topology graph (React Flow)Live
/documents/ingestUpload → pipeline-status timelineLive (role-gated engineer/admin)
/documents/compareSide-by-side version / metadata diffLive
/assets/registerRegister one asset, or bulk-import the EAM golden record as CSV (POST /assets/, /assets/bulk)Live (engineer/admin)
/assets/bootstrapMDM asset identity confirmation: provisional assets + unresolved tag aliasesLive (engineer/admin — mirrors the API)
/projectsEngineering + procurement registry by equipment classComposed from documents+assets+events
/graphTemporal knowledge graph (React Flow): the asset in the centre, what it touches on an inner ring, what only those reach (people, organisations, other assets) on an outer one. Elliptical rings with fixed 132 by 50 cards, auto-fit once every card is measured, zoom 0.05 to 2.5 (React Flow's default floor of 0.5 stopped zoom-out and fit on any large graph), a node click dims every other line, and a corner toolbar with Rearrange and Full screen (the browser's, else a portal over the window, because a fixed canvas inside the transitioning route would only fill its ancestor). The legend and validity windows sit under the canvasLive
/auditAudit trail — entity/action logLive
/eventsOperational event surfaces + demo emit formsLive
/events/[id]Event detail + ack + correlation; work-order events link to knowledge capture and a voice noteLive
/offboarding · /offboarding/[sessionId]Retiring-expert knowledge-transfer sessionsLive (role-gated engineer/admin)
/overviewPlant overview — KPIs, alerts, system healthLive
/overview/cross-siteCross-site pattern alerts (linked from the overview header)Live: GET /assets/cross-site-patterns (failure families repeating on an equipment class across sites); honest "one site, nothing to compare" empty state — no fixture
/overview/plant-statePlant operating-state controlLive (admin-gated write)
/overview/coverageKnowledge-coverage matrix (GET /assets/coverage)Live
/system-healthLive probes: 11 API surfaces + 5 datastores + OT historian connector registry + opt-in model probesLive (admin-only)
/settingsSystem settingsLive
/field/deviationPhysical deviation flag (freezes affected asset briefs)Live (mobile field capture)
/field/elicitation/[workOrderId]Knowledge-capture micro-interviewLive (mobile)
/field/voiceAd-hoc voice note — tag an asset/WO, record → quarantineLive (mobile field capture)
/field/voice/[workOrderId]Voice note tied to a specific work orderLive (mobile field capture)

Role-based route access is enforced centrally in AppShell: routeAllowed(path, role) + roleHome(role) in use-role.ts. Staff surfaces (/overview, /events, /rca, /graph, /compliance, /governance, /audit, /documents, /projects, /offboarding) require engineer/reliability/admin; /system-health is admin-only. A field worker who navigates to a gated URL is redirected to /briefs. Open-to-all routes (briefs, copilot, assets, /field/*, /settings) are unlisted. (/system-information and /system-benchmarks were removed 2026-09-22.) Field routes render at mobile width; there is no mobile bottom tab bar — mobile navigates via the hamburger sidebar (see §4).

Demo account. useRole() exposes isDemo. A demo user lands on the overview, may open every page, and every action works (ingest, supersede, resolve, promote, approve, plant state, asset registration, offboarding, voice, brief ack, countersign and rating) on the showcase plant (no banner says so), and the API refuses any write that is not on a showcase row. Only the controls the backend keeps closed to it (the model-gate run and the provider probes) are wrapped in DemoGate (components/demo-gate.tsx), which disables them with the tooltip "Not available in the demo". The button on the login page appears only when NEXT_PUBLIC_DEMO_EMAIL and NEXT_PUBLIC_DEMO_PASSWORD are set (baked at build time).

Client-only components that must not SSR (React Flow graph, blast-radius, supersede action) are loaded via dynamic(..., { ssr: false }) from the "use client" module components/lazy.tsx — Next 16 disallows ssr: false directly inside a Server Component page.


4. Navigation

AppShell (components/app-shell.tsx) renders two distinct navigations depending on role and viewport.

Desktop sidebar (316px, collapsible to 68px; slide-over drawer on mobile). There is no desktop top bar: the rail owns the global actions (sidebar-first, as in Linear / Vercel).

  • Top: Search (⌘K command palette) + Ingest (/documents/ingest)
  • (ungrouped) Overview (/overview, staff)
  • Operate: Briefs, Copilot, Assets, Events, Voice and Deviation (admin + field_worker only)
  • Analyze: RCA, Graph, Coverage
  • Assure: Compliance, Governance, Audit Trail
  • Knowledge: Documents, Projects, Off-Boarding
  • Foot: account row → menu with System Settings, System Health (admin only), Sign out; the collapse toggle sits beside it

No group headings are rendered (removed 2026-09-27). The grouping survives as spacing (--rail-section-gap) and as each list's aria-label, and the ⌘K palette still groups results by the same NAV section names.

Collapsing (68px) must not move anything. Two rules make that true, and breaking either one re-flows the icon column: [data-rail] .rail-link carries a min-height floor (a row sized by its content is 1.5px shorter once the label is hidden, which compounds), and the section gap is the same in both states. The logo row is a fixed 56px because the toggle lives at the foot, not under the mark. Collapsed also hides Ingest (68px fits one square; ⌘K and /documents still reach it) and the Governor pill (0/6 with no label is a riddle). Verified 0px drift on every row, engineer and admin, at 1440x900.

Mobile keeps a top bar: menu · search · + Ingest · account.

The field-capture items Voice (/field/voice) and Deviation (/field/deviation) are nav-gated to ["field_worker", "admin"] — so the admin sidebar is a full superset of every role's navigation. Engineer/reliability don't see them.

Active route is a square row with a 2px inset accent rule, ink text and the filled cut of its icon (bg-surface-2 … shadow-[inset_2px_0_0_var(--accent)]). The account row at the foot shows the signed-in email and role from GET /auth/me; its menu holds System Settings, System Health (admins) and Sign out, which clears tokens and redirects to /login. The sidebar logo is public/logo.png at 30px (square, like all chrome since the 2026-09-22 overhaul).

  • /system-health — admin-only live dashboard: probes all 11 cheap API surfaces + 5 datastores every 30s, plus an opt-in "AI models" section (NIM/Gemini/Jina/Groq) that probes GET /health/model?provider=… once/minute only when toggled on (each probe spends provider quota; off by default, persisted in localStorage).
  • Login (/login) has an "Explore the live demo" button that signs straight into the seeded admin account.

Browser tab titles — Kairos: <page> on every app route (landing//login = Kairos). Resolved server-side by generateMetadata in (app)/layout.tsx, which reads an x-pathname request header set by src/proxy.ts (Next 16's renamed middleware — it must set the header on the request, not the response, or headers() can't read it, which is why refresh used to fall back to bare "Kairos").

Knowledge coverage (/overview/coverage)

Per-asset coverage matrix — facts held, how many are authoritative (level 1-3), how many are human-verified, linked documents, pending quarantine. Backed by GET /assets/coverage (services/coverage.py), which is read-only and model-free, so refreshing it costs no provider quota.

Two deliberate choices worth keeping:

  • Sorted weakest-first. The page exists to show gaps, so the thinnest asset leads rather than being buried alphabetically.
  • Shading is relative to the best-covered asset, not an absolute scale. "How thin is this compared with the best equipment we have" is the question a reliability engineer asks; an absolute scale would paint a small corpus uniformly empty and say nothing.

The Verified column reads zero across the estate and is kept visible on purpose — promotion through the quarantine gate is human-only and nothing has been promoted, so the zero is the finding. Hiding the column would read as "not measured".


There is no mobile bottom tab bar. BottomTabs (never FieldBottomTabs — that name is from an older revision) was deleted from app-shell.tsx on 2026-08-15: it had been commented out since the mobile UX was deferred, so it was neither shipped nor removed, and it left isField orphaned. Mobile navigates via the hamburger sidebar. FIELD_ROLES gating is unaffected and still live. Recover the component from git history if the tab bar is revived.


5. Auth

File: src/lib/auth.ts

Real Supabase-backed login — not a mock bypass.

POST /auth/login  →  { access_token, refresh_token, user_id }

Tokens stored in localStorage under keys kairos-token and kairos-refresh. getMe() calls GET /auth/me with Authorization: Bearer <token>. logout() calls clearSession().

Two ways a session ends (lib/api.ts).

FunctionWhenWhat it removes
clearSession()Explicit sign-outBoth tokens, the kairos-access cookie, sessionStorage (Copilot history), every service-worker cache (LOGOUT message plus caches.delete), and the kairos-queue IndexedDB (QUEUE_DB). The next person on a shared tablet finds none of it
expireSession()A 401 the refresh token could not repairThe tokens, cookie and sessionStorage only. The offline queue and caches are kept, so a field worker whose session lapsed does not lose unsent acknowledgements and flags

Both fire a best-effort POST /auth/logout (keepalive, write timeout, never awaited, never allowed to throw) so the server revokes the session and a copied refresh token stops working; sign-out still works offline. Each wipe step is independent and wrapped in try/catch.

Offline queue is tagged with the user. idb.ts stores each queued write with the JWT sub (tokenUserId) of whoever made it and replays an entry only for that same user. An entry from someone else, or from a build that recorded no user, is dropped rather than sent (replaying it would file it under the current user). With nobody signed in, nothing is replayed or deleted.

Every route param is encodeURIComponent-ed in api.ts (a crafted id such as x/../../admin?y=1# stays one path segment), and getArtifactUrl only returns an http(s) URL, never a javascript: or data: one before it reaches window.open.

Auth guard in AppShell: no token → redirect to /login. /login redirects already-authenticated users to /briefs. Expired/invalid token (e.g. Supabase JWT past its 1-hour TTL): even in dev (non-strict) the shell now clears the session and redirects to /login rather than silently falling back to the engineer dev-default — that used to read as a surprise role downgrade (field_worker → engineer).

Seeded users (passwords are read from .env, never committed; see .env.example for the variable names):

EmailPassword (env var in .env)Role
admin@example.comKAIROS_SEED_PASSWORD_ADMINadmin
engineer@example.comKAIROS_SEED_PASSWORD_ENGINEERengineer
field_worker@example.comKAIROS_SEED_PASSWORD_FIELD_WORKERfield_worker
reliability@example.comKAIROS_SEED_PASSWORD_RELIABILITYreliability
compliance@example.comKAIROS_SEED_PASSWORD_COMPLIANCEcompliance
demo@example.comKAIROS_SEED_PASSWORD_DEMOdemo

Seed with: docker exec kairos-backend-api python scripts/seed_users.py


6. API Client

File: src/lib/api.ts

SSR-aware base URL

Server components (assets, briefs, documents, governance/conflicts) run fetch() inside the Docker container. API_BASE resolves differently depending on environment:

export const API_BASE =
  typeof window === "undefined"
    ? (process.env.API_INTERNAL_URL ?? process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000")
    : (process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000");
ContextURL used
Browser (client components)NEXT_PUBLIC_API_URL = http://localhost:8000
Server (SSR inside container)API_INTERNAL_URL = http://kairos-backend-api:8000

Exports

ExportPurpose
API_BASESSR-aware base URL (see above)
getToken()Reads kairos-token from localStorage; returns null server-side
getJson<T>(path, timeoutMs?)Unauthenticated GET — 4000ms default abort (slow cold Neo4j/Supabase); a genuine failure surfaces as error+retry, never a fixture. getComplianceGaps passes 5000ms.
getArtifactUrl(documentId)GET /documents/{id}/artifact-url — short-lived Supabase signed URL to open a vault artifact in the browser (the stored vault_url is the auth-only endpoint a plain link can't open).
postJson<T>(path, body)Authenticated POST — attaches Bearer token if present
getBriefs()GET /briefs/?unacknowledged_only=false&limit=20
getBrief(id)GET /briefs/{id}
getAssets()GET /assets/?limit=100
getAsset(id)GET /assets/{id}
getAssetAliases(id)GET /assets/{id}/aliases
getAssetKnowledge(id)GET /assets/{id}/knowledge
getKnowledgeGraph(id, asOf?)GET /assets/{id}/graph (the graph page: hierarchy, documents, people, organisations, other assets, events)
getComplianceGaps(fw?)GET /compliance/gaps
getConflicts()GET /governance/conflicts?limit=50
getQuarantine()GET /governance/quarantine?limit=50
resolveConflict(id, body)POST /governance/conflicts/{id}/resolve
getConflictDetail(id)GET /governance/conflicts/{id}
getTimestampDrift()GET /governance/timestamp-drift
getPushVolumeGate(days)GET /governance/push-volume-gate?days=
getDocumentExtraction(id)GET /documents/{id}/extraction
getRedactedDocument(id)GET /documents/{id}/redacted
getAssetHierarchy(id)GET /assets/{id}/hierarchy
searchAsset(id, q)GET /search/assets/{id}?q=
releaseHeldDocument(id, note) · rejectHeldDocument(id, note)POST /documents/{id}/ocr-review/release · /reject
getOtConnectors()GET /health/connectors
promoteQuarantine(id, body)POST /governance/quarantine/{id}/promote
disputeQuarantine(id, reason)POST /governance/quarantine/{id}/dispute
getDocuments()GET /documents/?limit=50
getDocument(id)GET /documents/{id}
synthesize(query, asOf?, onSources?, onDelta?)POST /search/synthesize — or POST /search/synthesize/stream when onDelta is supplied. It still sends the context it retrieved client-side, but the server ignores it and judges the answer on its own retrieval; the client keeps that context only to re-attach titles, excerpts and links to the response's sources by document_id. A source the server found and the client did not shows its id as the title with no excerpt
getRcaPack(assetId, code)POST /search/rca-pack
ackBrief(id, body)POST /briefs/{id}/ack
sendBriefFeedback(id, rating, notes)POST /briefs/{id}/feedback
submitAnswerFeedback({query, rating, …})POST /search/feedback
getMoc(id)GET /governance/moc/{id}
getMocs()GET /governance/moc
approveMoc(id, note?)POST /governance/moc/{id}/approve
getSlaReport()GET /governance/sla-report
getCircuitBreaker()GET /governance/circuit-breaker
getModelGateHistory()GET /governance/model-gate/history
runModelGate()POST /governance/model-gate/run
getValidationCorpusStats()GET /governance/validation-corpus/stats
getKnowledgeGraph(assetId, asOf?)GET /graph/asset/{id}?as_of=…
getAuditLog(entityId?, type?)GET /audit/log
getDocumentTopology(id)GET /documents/{id}/topology
getPlantState(siteId)GET /events/plant-state/{site_id}
setPlantState(body)POST /events/plant-state
getComplianceDashboard()GET /compliance/dashboard
getBlastRadius(documentId)GET /governance/blast-radius/{doc_id} — normalized (see below)
getAuditPack(framework)GET /compliance/audit-pack?framework=…
getEvents() / getEvent(id) / ackEvent(id)operational event surfaces
postTagOut / postAlarm / postShiftHandover / postInspectionComplete / postDeviationFlagdemo event emitters
getMocList() / getMoc(id) / approveMoc(id)Management of Change
ingestDocument(form) / getDocumentStatus(id) / supersedeDocument(id, newId)document ingest (the result carries authority_level, authority_requested, authority_capped) + pipeline status + supersede. supersedeDocument returns SupersedeResponse, whose status is superseded or pending_moc_approval (HTTP 202, authority 1 to 3): callers must branch on status, never treat a 2xx as done
confirmAssetIdentity(...)MDM identity confirmation (asset bootstrap)
triggerElicitation / getElicitationQuestions / submitElicitationResponsesfield micro-interview
getOffboardingList / getOffboarding / getOffboardingQuestions / submitOffboardingResponses / createOffboardingretiring-expert knowledge transfer
submitVoiceNote(id, blob, user)POST /elicitation/{id}/voice (field name file)
createAnnotation / getAnnotations / getAnnotationStatsinline document annotation
getGovernorState / getPlantState / setPlantState / getHealthDetailed / getOtCoverageops + system state

Response normalizers

Several backend endpoints return a shape that does not match the flat type the UI consumes. Rather than scatter mapping logic across components, the fetcher normalizes the response (adapter pattern) so components stay dumb:

FetcherBackend shape → UI shape
getBlastRadius{affected:[{edge,source,target}]} → {items:[{item_id,item_type,description,asset_id,flagged_for_review}]}. The affected entity is the edge source (e.g. the asset), not the target (the document node); items are deduped by edge_id server-side.
getDocumentTopology{topology:{equipment_nodes,isolation_valves,isolation_boundaries,instrumentation_loops}} → flat {nodes,edges} (synthesises boundary→valve/bleed edges)
getOffboardingList{items,total} → OffboardingProgramme[] (unwraps .items)

Types corrected to mirror the real backend contract (verify with a live curl before trusting a type): ComplianceDashboard.total_gaps is {critical,major,minor} (an object, not a number); SlaReport is an escalation report (overdue_conflicts[] · overdue_quarantine_items[] · overdue_*_total · escalated_this_run · checked_at; no on-time tallies); CircuitBreakerState is {states[],halted_count} with boolean halted; ValidationCorpusStats is {total_corpus_size,by_entity_type,last_updated_at} (no by_asset_class).

Live-only data policy (no fabricated data)

The app never renders a fixture, and this is enforced by the type system rather than by convention: DataSource is a single-member union ("live", api.ts:250), so no fetcher can return a fixture source. Fetchers throw on failure. The user always sees real data, a loading skeleton, or an error + retry:

  • useFetch turns a rejected fetcher into the page's error+retry state; loading → skeleton.

  • Server pages throw → shared (app)/error.tsx + (app)/loading.tsx. (app)/layout.tsx exports dynamic = "force-dynamic" so these render per request (a build-time prerender would otherwise hit the throw with no backend).

  • Custom-client pages show an inline "unavailable — retry".

  • getComplianceGaps treats empty live results as valid (no fixture on empty) and uses a 5 s timeout.

  • /overview/cross-site reads GET /assets/cross-site-patterns and shows each pattern with every site's work-order count, affected assets and last-seen time; with one site it says there is nothing to compare.

  • Streamed answer text is provisional, never the answer. Passing onDelta to synthesize() switches it to the SSE endpoint for progressive render (p95 synthesis is ~65 s, so without it the operator watches a spinner for over a minute). The text is held on Turn.streaming and rendered by Answer's streaming prop only while is_synthesizing — it is never merged into answer. The backend can still turn a finished answer into a refusal, and it withholds text entirely for safety-critical categories, so rendering a delta as final would show a claim the safety gate is about to retract. On a restart event the accumulated text is cleared, because the fallback cascade produced a different answer and concatenating the two would fabricate a hybrid.

  • synthesize() and getRcaPack() throw — they return a bare value, not Fetched<>, so useFetch's guard never covered them. They previously returned a hardcoded answer with invented document IDs on any failure, rendered identically to a real cited answer. /copilot now shows a per-turn AnswerError (role="alert" + Retry) and /rca its existing failed state. Zero search results is a real outcome: {answer: null, sources: []}, never a fixture.

  • /documents/[id]/topology discloses backend fixture use. The ingest pipeline falls back to fixtures/pid_topology_mock.json when the vision model is unreachable, stamping topology_source: "demo_fixture". That returns source: "live" with plausible-looking elements, so it used to render as if the drawing had been parsed. getDocumentTopology now carries the flag through and the page badges it "Fixture — vision model unavailable".

  • submitAnswerFeedback() is deliberately fire-and-forget — it resolves false instead of throwing. A failed rating must never surface as an error over the answer the user is reading, so the control clears its selection and shows "Rating not saved" rather than claiming a save that did not happen. This is the one fetcher whose failure is not an error state, and it is exempt from the throw rule because it returns a bare boolean, not Fetched<>.

  • /copilot renders pending_moc above the answer. POST /search/synthesize returns open engineering conflicts awaiting MoC sign-off for any cited asset; the banner sits above the prose because after it a technician has already read the number.

Default read timeout is 4 s (getJson) and 8 s for writes (postJson). The two endpoints that run NIM synthesis — synthesize() and getRcaPack() — share SYNTHESIS_TIMEOUT_MS (90 s), which the backend's NVIDIA_NIM_TIMEOUT must stay under. Keep them on the one constant: getRcaPack silently inherited the 8 s write default until 2026-08-23, so /rca rendered its retry state over a request that was completing normally. Any page on that budget must say the wait is expected, or a correct 90 s response reads as a hang.

Guard array reads defensively — x?.arr.length still throws when arr is undefined (use ?? []).

confidence is number | null, and null is not 0. The backend returns null when a synthesis carries no parseable CONFIDENCE: marker — ~23% of non-refused answers — so coercing it made good answers render "Low confidence · 0%" with an empty meter. ConfidenceMeter takes number | null and shows "Confidence not reported" for null; the < 0.7 uncertainty branch must never fire on it. Same rule on RcaPack.confidence.

There are no fixtures left. DemoChip and lib/{fixtures,assets,governance,documents,events,compliance}.ts were deleted on 2026-08-15, and DataSource narrowed to a single member ("live") so a fallback cannot return without a type error. Fetchers throw instead. Three of those paths were not dead when removed — getEvents, governance/moc and governance/model-gate were rendering fabricated data on successful requests. lib/copilot.ts and lib/rca.ts survive for live types and real constants (SUGGESTIONS, RCA_PRESETS) plus the test-only rcaFor.


7. Types

File: src/lib/types.ts — single source of truth derived from the backend Pydantic shapes.

Key types:

TypeDescription
Role"admin" | "engineer" | "field_worker" | "reliability" | "compliance" — compliance is the read-only auditor persona
AuthorityLevel1–5 (lower = higher authority, per Architecture Layer 4)
BriefFull brief shape including sources, warnings, countersignature flag, freeze state
BriefsResponseBrief list + governor_state + suppressed_count + suppressed_held (the withheld briefs themselves, rendered under the governor banner — a count alone cannot tell an operator whether the held brief concerns their asset) + next_delivery_allowed_at
GovernorStatepush_count_last_hour, ceiling, state
Asset / AssetDetailAsset list item vs full detail with open_work_orders_count, compliance_gap_count
KnowledgeConflict / QuarantineItemGovernance types with sla_due_at, is_overdue
VaultDocumentDocument with vault_url, ocr_confidence, supersede chain
SynthesizeResponseCopilot answer shape from POST /search/synthesize
RcaPackTimeline + hypotheses + supporting docs from POST /search/rca-pack
ListEnvelope<T>{ items, total, limit, offset } — standard list shape

8. Shared UI Primitives

File: src/components/ui.tsx

ComponentPropsRenders
AuthorityBadgelevel: AuthorityLevelNeutral badge reading L{n} · {name} (e.g. L3 · OEM). Hover text from authorityDescription() says what the level is and that 1 is highest
StatusBadgetone: Tone ("danger" | "caution" | "verified" | "info" | "validation" | "neutral")Pill badge
SourceChipquarantine?: booleanDocument ID chip; orange ring if quarantine
Modalopen, onClose, title, childrenOverlay modal for promote/dispute actions
Buttonvariant, size, onClickPrimary/ghost button with active press state

File: src/components/use-role.ts

useRole() — reads role from live user profile via getMe(). PROMOTE_ROLES = ["reliability", "admin"] (matches OPA can_promote_quarantine — engineers resolve conflicts but do not promote quarantine). ADMIN_ROLES = ["admin"] gates Identity confirmation, plant-state write, model-gate Run, and System Health.

SystemTabs (components/system-tabs.tsx) joins the two system surfaces — Health · Settings — into a tabbed sub-navigation, rendered at the top of each. (/system-information and /system-benchmarks were removed 2026-09-22.) Admin-only tabs are hidden for non-admins; routeAllowed remains the enforcement point. A single visible tab (non-admin sees only Settings) renders nothing — a lone tab is not navigation. usePathname() is guarded with ?? "" because it returns null without router context.

File: src/components/skeleton.tsx

PageSkeleton — shared loading placeholder used by (app)/loading.tsx.


9. Frontend fixtures — none remain

The frontend fixture system was deleted on 2026-08-15 and cannot come back without a type error. lib/{fixtures,assets,compliance,documents,governance}.ts and the <DemoChip> primitive (with all 10 render sites) are gone; DataSource narrowed to the single member "live". Verified 2026-08-17: none of those files exist and DemoChip appears nowhere in frontend/src.

Three of those paths were not dead when removed — getEvents, governance/moc and governance/model-gate were rendering fabricated data on successful requests, not just on failure.

Two files survive deliberately and are not fixture modules:

FileWhy it stays
lib/copilot.tsLive types, the real SUGGESTIONS constants, and metaAnswer() (handles "what can you do?" locally, with no API call, rendered as a labelled "About Kairos" card carrying no sources — it is not retrieved knowledge and must never look like a governed claim)
lib/rca.tsLive types + RCA_PRESETS, plus rcaFor which is TEST-ONLY (mocks getRcaPack in rca/page.test.tsx). Importing it from api.ts would reintroduce fabricated hypotheses on failure.

Backend fixtures are a separate matter and are mock-by-design — see FIXTURES.md. The P&ID vision fallback (fixtures/pid_topology_mock.json) is live and must be disclosed in the UI (§6).


10. Data source — live-only

Every data page is live (real backend data). There is no "demo fallback" column anymore: when a fetch fails, the page shows a loading skeleton then an error + retry, never a fixture (see §6). The /overview overview aggregates 5 core live fetches (conflicts · quarantine · SLA · compliance · events); system health is fetched separately on its own useFetch so a slow/failed cloud ping (/health/detailed pings every store, ~2.5s) shows an inline "unavailable" strip instead of blanking the whole page. /governance hub + /overview/cross-site are the only surfaces with no primary data fetch (hub = static links; cross-site = live patterns, or "one site, nothing to compare").

Write contracts (resolve/promote/dispute, ingest, sign-off) are role-gated: field_worker sees a read-only view; action buttons are hidden via useRole(). Quarantine promote is reliability/admin only (matches OPA); model-gate Run and asset Identity confirmation are admin only.


11. Design Tokens

Defined in globals.css as CSS custom properties, available to all Tailwind utilities.

TokenRole
--canvasPage background
--surfaceCard/sidebar background
--surface-2Nested card background
--lineBorder colour
--inkPrimary text
--mutedSecondary text
--accentBrand colour (Kairos Orange)
--accent-softTinted accent background
--on-accentText on accent backgrounds
--verifiedGreen (authority verified)
--cautionAmber (unverified / warning)
--caution-inkAmber text on a caution tint (StatusBadge). Darker than --caution in light mode so an 11px badge clears AA 4.5:1; same as --caution in dark
--dangerRed (refused / critical)

Light and dark values are set via [data-theme=light] / [data-theme=dark] on <html>. Theme is persisted in localStorage under kairos-theme.

Landing page tokens — a separate, scoped set

The public landing page (app/page.tsx) is a marketing surface with its own visual language and does not use the app tokens above. Its tokens are namespaced --lp-* and scoped to .landing, so nothing leaks into the app.

TokenValueRole
--lp-bg / --lp-band#ffffff / #f8f8f8page ground / alternating band
--lp-ink / --lp-muted#0b1015 / #3f3f3fheadings / body
--lp-line#e5e3dfhairline, used for the frame rails
--lp-accent#ff3c00display type ≥24px, fills, rules, corner marks
--lp-accent-strong#d93400any fill carrying small white text
--lp-accent-text#cc3100small accent text on light
--lp-dark / --lp-dark-2#0a0a0a / #232323dark bands

Three accents exist because #ff3c00 only reaches 3.35:1 on the light ground. It is therefore confined to jobs where 3:1 is conformant (large display type, fills, decoration); anything smaller uses one of the accessible variants. The landing is deliberately light-only — its dark bands are part of the composition, not a theme — so it carries no [data-theme] pairs.

Typography is Instrument Sans (display) + DM Sans (body) via next/font, declared in app/landing-fonts.ts so the landing and the workspace load one copy of each. The root layout adds Geist Mono (tabular numerals, codes and IDs) and Noto Sans Devanagari (Hindi/Hinglish content).

Three things to know before editing it:

  1. globals.css has an unlayered * { border-color: var(--line) }. Unlayered declarations outrank everything in @layer utilities regardless of specificity, so that rule silently beats every Tailwind border-* colour utility. The landing is excluded from it via *:not(.landing, .landing *); without that exclusion, border-(--lp-accent) renders as the hairline colour.
  2. .landing sets overflow-x: clip because the corner ticks are centred on the frame edge and hang 5px outside it. Use clip, not hidden — hidden makes it a scroll container and breaks the sticky header.
  3. Tailwind v4 leaves <button> at cursor: default. .landing button restores cursor: pointer once, rather than per-button.

Product screenshots in public/shots/ are real captures of the running app, produced by a local-only script (git-ignored; see .gitignore). Re-run it after UI changes so the marketing page does not drift from the product.

The system-design diagrams in public/diagrams/ are pre-rendered SVG, not a runtime mermaid dependency. docs/DIAGRAMS.md is the source of truth — one mermaid block per tab id — and ./tools/render_diagrams.sh regenerates the whole set from it. Edit the markdown, then re-run the script; never hand-edit an SVG, since the next render silently discards it. Two details the script exists to keep consistent: -b white (the diagrams' palette is built for a white ground, and without it mermaid emits a transparent SVG whose cluster fills and edge labels resolve wrong), and stamping the viewBox's pixel size back onto the root element (mermaid-cli emits width="100%", which leaves the file with no intrinsic size, so the <img> cannot reserve its box and the small diagrams get upscaled to fill).


12. Docker

frontend/Dockerfile is 4-stage: deps → dev → builder → runner.

StageRole
depsnpm ci from the lockfile — the cached dependency layer
devnext dev with HMR; this is the target the compose override builds
buildernext build (needs output: "standalone" in next.config.ts)
runnerserves the standalone build as non-root user nextjs — the prod target

Full build-system detail: DOCKER.md.

docker-compose mounts (dev override only — the base file bakes code into the image):

volumes:
  - ./frontend/src:/app/src
  - ./frontend/public:/app/public
  # node_modules stays inside the image layer

Env vars:

NEXT_PUBLIC_API_URL=http://localhost:8000   # browser → host port-mapping; embedded at next build
NEXT_PUBLIC_AUTH_STRICT=true                # build arg (Dockerfile + compose) and, for the local dev container, a runtime variable; baked in like the API URL. true turns a 401 into a forced re-login. Vercel and the local .env both use true; false is only the code default
API_INTERNAL_URL=http://kairos-backend-api:8000  # SSR → Docker internal network
NODE_ENV=development

Healthcheck polls http://localhost:3000 every 30s; 90s start period for initial npm run dev boot.

Rebuild required when package.json or package-lock.json changes:

docker compose up -d --no-deps --build kairos-frontend

13. CI/CD

.github/workflows/frontend.yml runs on every push/PR that touches frontend/ or the workflow file itself. It is one job (ci, one npm ci) whose steps run sequentially and fail fast:

StepCommandWhat it checks
Type checknpx tsc --noEmitTypeScript strict — zero errors required
Lintnpm run lintESLint (Next.js config) — zero errors required
Buildnpm run buildFull Next.js production build — catches missing imports, invalid RSC boundaries
Audit (production)npm audit --audit-level=high --omit=devCVEs at high/critical severity in what ships to a browser
Audit (dev)npm audit --audit-level=highReport only (continue-on-error)

No secrets are needed. Vitest is not a CI step: run it with the command in §14.

-----|---------|----------------| | typecheck | npx tsc --noEmit | TypeScript strict — zero errors required | | lint | npm run lint | ESLint (Next.js config) — zero errors required | | build | npm run build | Full Next.js production build — catches missing imports, invalid RSC boundaries | | audit | npm audit --audit-level=high | Dependency CVEs at high/critical severity |

All four jobs run in parallel on ubuntu-latest with node:20 and npm ci from the lockfile. No secrets are needed for these checks.


14. Quality Status

CheckStatus
TypeScript strict (tsc --noEmit)✅ 0 errors
No console.log in src✅ clean
No hardcoded hex in DOM (canvas exempt)✅ clean
No key={index} on dynamic lists✅ clean
useEffect cleanup (alive pattern)✅ all async effects have cleanup
h-screen → h-dvh✅ converted
Token colors only (no bg-white, text-gray-*)✅ clean
@xyflow/react in package-lock.json✅ resolved
Test suite373 tests / 85 files, all passing (2026-10-09; in a container mount benchmark/ at /benchmark for landing-figures.test.ts)
eslint✅ 0 errors (3 pre-existing unused-var warnings)

15. Progressive Web App / Offline

The offline shell is a production-only feature. public/sw.js is registered from AppShell only when process.env.NODE_ENV === "production"; in development it is actively unregistered, because a cached app shell fights Next.js HMR (stale chunk hashes trigger a hard reload that re-serves the stale cache — an infinite refresh loop). The service worker uses network-first for navigations (cache is an offline fallback only) and stale-while-revalidate for static assets; bump the SHELL cache version to bust a poisoned cache.

API reads are network-first and cached per user (kairos-data-v2). The old kairos-data-v1 cache was cache-first and unkeyed, so any user could be served another's data. Now a GET is fetched first, a successful response is stored under a key that includes the Bearer token's sub (/__api-cache/{sub}/{url}), and the cache is used only when the network fails. A request with no Bearer token, or one that is not a JWT, is not cached at all. Sign-out posts {type: "LOGOUT"} and the worker deletes every cache. By design the caches survive session expiry (expireSession), so a field worker can still read their last data offline; they are keyed by user, so the next person to sign in is never served them. Covered by lib/service-worker.test.ts, which runs sw.js against a fake worker scope.

The IndexedDB write-queue (lib/idb.ts, OfflineQueue) is app-level (not the SW) and works in dev: writes made offline are queued and flushed on the online event; the queued count shows in the shell.


15a. Security headers and CSP

next.config.ts sets four headers on every route (/:path*) through headers():

HeaderValue
Content-Security-Policybuilt by contentSecurityPolicy(apiUrl, dev), below
X-Content-Type-Optionsnosniff
Referrer-Policystrict-origin-when-cross-origin
Permissions-Policycamera=(), microphone=(self), geolocation=(), payment=(), usb=() (voice notes need the microphone on this origin only)

CSP directives: default-src 'self'; script-src 'self' 'unsafe-inline' (plus 'unsafe-eval' in development only, for HMR); style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; media-src 'self' blob:; connect-src 'self' <API origin> (derived from NEXT_PUBLIC_API_URL, falling back to http://localhost:8000 on a bad value); frame-src https://www.youtube-nocookie.com; worker-src 'self'; manifest-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'.

The unsafe-inline compromise. script-src keeps 'unsafe-inline' on purpose. Next 16 emits inline bootstrap and RSC-payload scripts on every page, so a hash cannot cover them, and a nonce needs per-request rendering, which would turn the static landing page and every cached shell dynamic. The rest of the policy is tight (no plugins, no foreign frames, no off-site form posts, only the API, YouTube's cookie-less embed and self-hosted fonts are reachable), but this means CSP is a second line of defence against XSS, not a guarantee, while tokens live in localStorage. Tested in lib/security-headers.test.ts. The API's own responses carry a separate header set from Caddy (DEPLOY.md).

15b. Document ingest and supersede UI

  • Authority levels (/documents/ingest and SupersedeAction): the selector offers L1 to L5 only to roles in AUTHORITY_ASSERT_ROLES (reliability, admin, in components/use-role.ts, matching the backend constant). Every other role sees L4 and L5 and a hint that levels 1 to 3 need reliability or admin, because the backend would cap them to 4 anyway.
  • Capped uploads are disclosed. When the ingest response has authority_capped: true, the ingest page shows an "Authority lowered" status line naming the requested and stored level.
  • Supersede is not always immediate. For an authority 1 to 3 document the backend answers 202 pending_moc_approval; SupersedeAction then shows "Awaiting MoC approval" (the old document is still current) with the MoC id and a link to /governance/moc/{id}, and tells the user to repeat the supersede with the same replacement after approval. A 403 (a role that may not supersede a level 1 to 3 document) gets its own message. The replacement is already stored in the vault in either case.

16. Intentional Non-Goals

These are deliberate decisions, not open gaps — the UI handles each honestly today:

ItemDecision
/overview/cross-site live dataLive since 2026-10-09. GET /assets/cross-site-patterns groups work orders by equipment class and failure family across the sites the caller's token covers. The golden plant is one site, so patterns come from the two showcase sites (and the golden assets appear as the exposed sister site); with one visible site the page shows an honest "nothing to compare" empty state. Free-text promotion between sites (which needs the PII redaction pass) is still not built: this page moves counts and codes only.
SSR bearer tokenLogin mirrors the access token into the kairos-access cookie in every mode, and every read sends the signed-in user's token (browser: storage; server components: the cookie). Strict mode only decides whether a 401 forces a re-login. The backend dev bypass applies only to a request with no session at all — it used to answer every SSR read, which showed a field worker the dev user's inbox and 404'd their own briefs.
HttpOnly session cookiesNot possible here. The frontend (Vercel) and the API (EC2) are on different sites, so a cookie the API sets is not sent on the frontend's cross-site fetches and cannot be read by server components. Tokens stay in localStorage and the kairos-access mirror cookie, which is why the CSP below matters
Offline app-shellProd-only by design — the service worker is disabled in dev (it fought HMR). The IndexedDB write-queue (idb.ts) works in dev.

Browser verification: ✅ complete. Every desktop route + all field routes verified against the golden dataset (admin + field_worker sessions). Seven live-data crashes were found and fixed during the sweep (now guarded by tests/test_contract.py) — see the response-normalizer note in §6 and implementation/status.md § Known Pitfalls.


Conformance updates — 2026-08-16

UI changes landed with the architecture-conformance work.

SurfaceChange
components/brief-detail.tsxPTW dual sign-off now works. Step 1 genuinely calls ackBrief (it previously called no API at all and only set local state); step 2 calls countersignBrief as a different authenticated user. Driven by server truth (acknowledged_by / countersigned_by), not local step state, so the two signatures can come from two sessions. The typed-name "shift lead" input is gone — identity comes from the session.
components/use-role.tsNew useMe() — identity, not just role, because the countersigner is compared to the acknowledger by user id.
documents/[id]/topologyConfirm/Reject per element (role-gated on RESOLVE_ROLES), a canonical-gate panel showing safety-critical progress, and a rewrite onto the shared useFetch hook.
documents/[id]handwriting_suspect chip — image-path documents only, excluding pid_drawing (a drawing is an image but carries no handwriting).
components/ui.tsx · app-header.tsx (header since deleted)PhaseBadge reads the live phase from /health/detailed instead of a build-time env var, and is actually rendered. It renders nothing until the phase is known, so it can never assert an unconfirmed phase. Removed 2026-09-22: a rollout stage is operator detail, not user information; the phase is still enforced server-side and reported by /health/detailed.
overview/cross-siteEyebrow corrected (since removed with all eyebrows).
governance/circuit-breaker?? FIXTURE fallback removed (it invented halted breakers for "Valve" and "Separator"). Its test was inverted, not deleted: one case renders live data, one pins that an empty response renders no invented rows.

Two live-only violations found and removed

Both were page-local FIXTURE constants that survived the 2026-08-15 sweep, which targeted the lib/*.ts fixture modules:

  1. documents/[id]/topology — data ?? FIXTURE, including elements labelled "verified". Fabricated engineering data rendered through the very verification gate being built.
  2. governance/circuit-breaker — state.data ?? FIXTURE.

When sweeping for fixtures, grep ?? FIXTURE, ?? demo, || FIXTURE across app/**/page.tsx — not just the fixture modules. A page-local constant is the same defect at a different address.

A bug worth remembering

briefs/[id]/page.tsx is a server component. In dev the SSR fetch carries no token, so the backend resolves it as dev-user/engineer regardless of who is logged into the browser. Any per-user authorization on a server-rendered page must account for that.