Skip to content

Mock-vs-real boundary

The v1 demo is a marketing surface. Its job is to render a credible supervision workspace with no backend. Production is the same product with persistent storage, auth, audit, and the workers that keep numbers fresh. The shapes do not change at the boundary; only the persistence and enforcement layers do.

This page lists every concern with its demo implementation and its production swap-in, then expands each row.

Surface or concernv1 demoProduction
AuthenticationPersona switcher in chromeEmail + password + TOTP, session cookie
Tenant isolationSkin id in URL and ZustandPostgres row-level security on tenant_id
AR register datalib/fixtures.ts, ~150 ARsPostgres appointed_reps table, FCA Register reconciliation worker
Risk scorePre-baked in fixturesRecomputed nightly and on event ingest
MI returnslocalStorage drafts, in-memory submitted statePostgres mi_returns, anomaly worker, immutable on submit
Breach reportsIn-memory, SUP 15 clock from fixed now()Postgres + scheduled job for approaching deadlines
FCA notificationRecords timestamp in storeBackend writes audit chain entry, generates CSV bundle
File reviewsPer-skin rubric in lib/rubrics.ts, scoring in clientSame rubric file as seed, server-side scoring on complete
Annual review packetAggregates from fixtures on renderServer-side aggregation worker, PDF export via Lambda
Audit logAppend-only array in storePostgres table with SHA-256 chain, ten-year retention
EmailNonePostmark transactional
File storageInline Attachment shapes onlyS3-compatible store, signed URLs, virus scan
RBACPersona-gated UI onlyServer-side enforcement in every handler
Rate limitingNoneToken bucket per session and per IP at the edge
SearchClient-side filterPostgres full-text plus pgvector for semantic case search
Skins / tenantsThree skins in URLEach principal firm = a tenant row

Demo: useDemoStore.persona toggles between "principal-compliance-officer" and "ar-user" in a chrome control. There is no concept of identity. The walkthrough script triggers a one-time confirmation modal on first switch.

Production: email + password with TOTP, opaque session cookie (httpOnly Secure SameSite=Lax). Session row in Postgres holds userId, tenantId, role, arId, expiresAt. Middleware validates the cookie, loads the row, sets the app.tenant_id GUC, attaches a session to the request. Step-up auth (re-enter password and TOTP) gates terminal actions. See Persona and tenant model.

Demo: skin query param and useDemoStore.skin select which fixture set to render. There is no boundary; switching skins switches the entire UI.

Production: every tenant-scoped table has tenant_id and an RLS policy filtering on current_setting('app.tenant_id')::uuid. The application connection role is subject to FORCE ROW LEVEL SECURITY so the policy applies even to the table owner. A migration check refuses to deploy any tenant-scoped table without RLS. The handler layer also asserts record.tenantId === session.tenantId as defence in depth.

Demo: lib/fixtures.ts generates ~150 ARs across three skins from a seeded mulberry32 PRNG. Names, FRNs, permissions, addresses, breach history, file reviews, MI returns, conduct events, risk trajectory all generated deterministically so server and client agree at hydration.

Production: appointed_reps is a Postgres table populated on AR onboarding. A nightly FCA Register reconciliation worker pulls the FCA’s published register, matches by FRN where present, and flags drift (status changes, permission revocations, principal changes). Drift items appear on the principal dashboard until acknowledged.

Demo: each fixture AR carries a riskScore field assigned at generation time using bandFromScore. The score is static; the AR-side flow that submits an MI return triggers no recompute.

Production: the score is recomputed (a) nightly for every AR in every tenant, (b) on every event ingest that touches an input (breach status change, file-review complete, MI-return submit, annual-review sign-off), (c) tenant-wide on a risk-weights change. Each recompute writes a history row so the trajectory chart reflects the change. See Risk-scoring.

Demo: AR-side draft state is held in useDemoStore and persisted to localStorage. Submission appends to liveMIReturns. The principal-side surface reads [...fixtures.miReturns, ...liveMIReturns] so a return submitted on the AR side appears immediately in the principal view.

Production: drafts persist as Postgres rows with status = "draft". Submission is a POST /api/mi-returns/:id/submit, idempotent on (tenantId, arId, period). On submit the row becomes immutable except for status transitions and the anomaly worker’s anomalyScore write. The anomaly worker runs the z-score computation against the AR’s 8-quarter trailing distribution and triggers a risk recompute.

Demo: useDemoStore.liveBreaches carries breaches filed during the session. The SUP 15 clock is rendered from a fixed now() (2026-05-08T18:00:00Z) so countdowns are deterministic. The principal triage queue reads [...fixtures.breaches, ...liveBreaches].

Production: breaches persist in Postgres. A deadline alerter cron job runs hourly, scans for NotifiableToFca breaches with notifyByAt - now() < 24h that are not yet notified, and pages the principal-admin via email and SMS. Past-due deadlines do not block notification submission; the audit chain captures the late status.

Demo: notifyFcaAt is set in the store on click. The chrome shows a toast.

Production: POST /api/breaches/:id/notify-fca requires step-up auth. The handler writes notifiedFcaAt, appends an audit event with action: "breach.notify-fca", generates a CSV bundle of the breach record and supporting context, and stores the bundle in object storage with a signed URL recorded on the audit event. The CSV format is the firm’s internal record; the FCA notification itself goes via Connect, which is out of scope for v1 and tracked as a v2 integration.

Demo: lib/rubrics.ts ships three canonical rubrics (MCOB, ICOBS, CONC). Scoring runs in the client; submitting completes the review locally.

Production: the same rubrics.ts file seeds the database on first run. Future per-tenant tunability writes to a rubric_overrides table; the demo’s canonical set remains the default. Scoring runs server-side on POST /api/reviews/:id/complete, which locks findings, computes the score, writes an audit event, and triggers a risk recompute for the AR.

Demo: the packet is aggregated from fixtures on each render. Sign-off sets a name and timestamp in memory.

Production: a server-side aggregation worker materialises the packet’s reference lists (breachSummaryRefs, fileReviewSummaryRefs, miReturnRefs, conductEventRefs) when the cycle opens, and refreshes them on each related entity’s update. PDF export runs in a Lambda, written to object storage, signed URL recorded on the audit event. Sign-off requires step-up.

Demo: a slim AuditEvent[] accumulates in memory during the walkthrough so the auditor view has something to render. No hash chain.

Production: append-only audit_events table with a SHA-256 prev-hash chain. Each row’s hash covers {id, tenantId, at, actorUserId, actorRole, action, subjectType, subjectId, ip, userAgent, prevHash} canonicalised to JSON. A nightly integrity job recomputes the chain and pages the firm on mismatch (P1 incident). Retention is 10 years (see retention table in Persona and tenant model).

Demo: no email. The “your principal sent you a comm” panel on the AR dashboard is a fixture string.

Production: Postmark for transactional email (invitations, password resets, deadline alerts, sign-off notifications). Templates owned by the firm, branded per tenant. SPF, DKIM, and DMARC required.

Demo: Attachment is documented in types but not rendered. No upload control.

Production: S3-compatible object store. Uploads via signed PUT URLs from POST /api/attachments. Each upload is virus-scanned; failed scans are quarantined. Downloads are signed GETs with a 5-minute TTL. Object keys are prefixed with tenant/<tenantId>/... and bucket policy enforces the prefix per tenant role.

Demo: persona-gated UI only. The <PersonaSwitcher> component reads useDemoStore.persona and renders the matching shell. There is no enforcement.

Production: every handler asserts session.role against the action’s required roles and step-up requirements. RLS handles tenant scope; the handler handles role and arId scope. See the RBAC matrix in Persona and tenant model.

Demo: none.

Production: token bucket at the edge, per session and per IP. Limits per route in API routes. Rate-limit responses return 429 with Retry-After. Auth routes have stricter limits and lockout (10 password fails per email per hour).

Demo: client-side filter on the AR register and breach queue. Case-insensitive substring match on tradingName, legalName, frn.

Production: Postgres full-text search across appointed_reps (trading name, legal name, FRN, city), breach_reports (title, description), file_reviews (case ref, notes). Plus pgvector embeddings on file-review notes and breach descriptions for semantic case search (“show me cases like this one”).

Demo: three hard-coded PrincipalFirmSkin rows in lib/skins.ts (Heritage, Crown, Pinpoint). The skin id appears in the URL and selects fixtures.

Production: each principal firm is a Tenant row. Skin-specific fields (legalName, frn, vertical, rubric, brandHex) live on the row. The demo’s three skins map to three seeded tenants so the demo data is loadable for QA.

The TypeScript type definitions in lib/types.ts, the rubric content in lib/rubrics.ts, and the risk-scoring formula in lib/risk-scoring.ts are identical in demo and production. The Zod schemas in lib/schemas.ts (production) validate the same shapes the demo’s TypeScript types describe. The component tree (components/principal/*, components/shell/*, components/ui/*) is unchanged; only the data source switches from fixtures.ts plus store reads to fetch('/api/...') plus React Query.