Data model
The demo’s types live in lib/types.ts. This page reproduces the shapes verbatim from source, then specifies the production-only extensions (Tenant, User, AuditEvent with hash chain), the Zod schemas used for validation, and the indexing and row-level-security pattern.
All persisted entities in production carry id (ULID), tenantId (ULID), createdAt (ISO 8601 timestamp with offset), updatedAt (same). Soft-deleted entities additionally carry deletedAt. Field naming is camelCase. Type names are PascalCase. Currency is GBP integer pence on the wire and on disk; rendering to GBP happens at the component boundary.
Primitive aliases
Section titled “Primitive aliases”export type Ulid = string;export type IsoTimestamp = string;export type Frn = string; // FCA Firm Reference Number, 6-7 digitsexport type Pence = number; // Integerexport type Vertical = "mortgage" | "general-insurance" | "credit-broking";export type RubricCode = "MCOB" | "ICOBS" | "CONC";export type ArType = "AR" | "IAR";export type RiskBand = "low" | "moderate" | "elevated" | "high" | "critical";export type Persona = "principal-admin" | "principal-compliance-officer" | "ar-user";
export type Role = | "principal-admin" | "principal-compliance-officer" | "ar-user" | "fca-auditor";Persona is the demo’s chrome-level role for the persona switcher. Role is the full RBAC enum used in production for session and audit records. fca-auditor is read-only and time-bound (see Persona and tenant model).
Address
Section titled “Address”export interface PostalAddress { line1: string; line2: string | null; city: string; postcode: string; country: "GB";}UK-only at v1. postcode validated against /^[A-Z]{1,2}\d[A-Z\d]?\s*\d[A-Z]{2}$/i server-side.
AppointedRep
Section titled “AppointedRep”The supervised firm or individual. IARs (“Introducer Appointed Representative”) share the principal’s FRN; ARs may carry their own.
export type ArStatus = | "pending-appointment" // Within the SUP 12.7 / PS22/11 30-day FCA notification window. Read-only; no transactions yet. | "active" | "suspended" | "under-investigation" | "terminated";
export interface AppointedRep { id: Ulid; type: ArType; tradingName: string; legalName: string; frn: Frn | null; status: ArStatus; permissions: Permission[]; city: string; appointedOn: IsoTimestamp; lastAnnualReviewAt: IsoTimestamp | null; nextReviewDueAt: IsoTimestamp; /** Composite risk score 0-100. */ riskScore: number; riskBand: RiskBand; isSelfEmployed: boolean; supportsImportantBusinessService: boolean; contact: { name: string; email: string };}
export interface Permission { code: string; label: string; grantedOn: IsoTimestamp; revokedOn: IsoTimestamp | null;}Production extends AppointedRep with tenantId, registeredOffice: PostalAddress (replaces the demo’s city-only field), createdAt, updatedAt, deletedAt. riskScore becomes integer scaled by 100 on disk (0-10000) to avoid float drift. isSelfEmployed flags PS22/11 enhanced-supervision scope; supportsImportantBusinessService flags SYSC 15A operational-resilience scope.
Indexes: (tenantId, status), (tenantId, riskBand, riskScore DESC), (tenantId, nextReviewDueAt), (tenantId, frn) unique where not null.
BreachReport
Section titled “BreachReport”export type BreachCategory = | "conduct" | "financial-crime" | "data-protection" | "complaints-handling" | "advice-suitability" | "disclosure" | "training-competence" | "other";
export type BreachSeverity = "minor" | "moderate" | "material" | "significant";export type BreachCustomerImpact = "none" | "potential" | "actual-low" | "actual-high";export type BreachStatus = "open" | "in-remediation" | "resolved" | "closed";
export interface BreachReport { id: Ulid; arId: Ulid; title: string; description: string; category: BreachCategory; severity: BreachSeverity; customerImpact: BreachCustomerImpact; awareAt: IsoTimestamp; reportedAt: IsoTimestamp; notifiedFcaAt: IsoTimestamp | null; /** Computed deadline by SUP 15.3 timing for the category and severity. */ notifyByAt: IsoTimestamp | null; rootCauseTaxonomy: string[]; status: BreachStatus; filedByPersona: Persona;}awareAt drives the SUP 15 clock. notifyByAt is computed server-side from the category and severity. Production replaces filedByPersona with filedBy: Ulid (the user id) and adds tenantId, createdAt, updatedAt.
Indexes: (tenantId, status, notifyByAt) for the triage queue, (tenantId, arId, awareAt DESC).
FileReview
Section titled “FileReview”export type FileReviewStatus = "scheduled" | "in-progress" | "complete" | "challenged";
export interface FileReviewFinding { itemCode: string; itemLabel: string; outcome: "pass" | "advisory" | "fail" | "n/a"; evidence: string; remediation: string | null;}
export interface FileReview { id: Ulid; arId: Ulid; caseRef: string; reviewerName: string; rubricCode: RubricCode; findings: FileReviewFinding[]; score: number; status: FileReviewStatus; startedAt: IsoTimestamp | null; completedAt: IsoTimestamp | null; rootCauseTaxonomy: string[]; notes: string;}score is derived: pass counts 1.0, advisory 0.7, fail 0.0, n/a excluded; mean across non-n/a findings, multiplied by 100. Production adds tenantId, reviewerId: Ulid, createdAt, updatedAt. findings.itemCode references a row in the rubric for the AR’s vertical (see File-review rubrics).
MIReturn
Section titled “MIReturn”export type MIReturnStatus = "draft" | "submitted" | "queried" | "accepted";
export interface MIReturnMetrics { newBusinessVolumeGBP: Pence; newBusinessCount: number; complaintsReceived: number; complaintsUpheld: number; breachesSelfReported: number; conductEventsLogged: number; cancellations: number;}
export interface MIReturn { id: Ulid; arId: Ulid; period: { year: number; quarter: 1 | 2 | 3 | 4 }; submittedAt: IsoTimestamp | null; status: MIReturnStatus; metrics: MIReturnMetrics; /** 0-1, computed against the AR's own historic distribution. */ anomalyScore: number;}Idempotent on (tenantId, arId, period). Production adds tenantId, submittedBy: Ulid | null, createdAt, updatedAt. After submitted, the row is immutable except for status transitions and anomalyScore recompute.
AnnualReview
Section titled “AnnualReview”export type AnnualReviewStatus = "draft" | "in-review" | "signed-off" | "rejected";
export interface AnnualReview { id: Ulid; arId: Ulid; cycleYear: number; status: AnnualReviewStatus; riskTrajectory: { at: IsoTimestamp; score: number }[]; breachSummaryRefs: Ulid[]; fileReviewSummaryRefs: Ulid[]; miReturnRefs: Ulid[]; signOffByName: string | null; signOffAt: IsoTimestamp | null; signOffNotes: string;}The packet is the SUP 12 / PS22/11 annual record. Production adds tenantId, signOffByUserId: Ulid | null (replacing signOffByName), conductEventRefs: Ulid[], createdAt, updatedAt. Sign-off requires step-up auth.
ConductEvent
Section titled “ConductEvent”export type ConductEventType = | "complaint" | "training-completion" | "supervision-1to1" | "policy-attestation" | "other";
export interface ConductEvent { id: Ulid; arId: Ulid; type: ConductEventType; occurredAt: IsoTimestamp; detail: string;}Production adds tenantId, attachments: Attachment[], createdAt.
Attachment (production-only)
Section titled “Attachment (production-only)”export interface Attachment { id: Ulid; filename: string; mimeType: string; bytes: number; storageKey: string; // Reference into S3-compatible store}RequiredAction
Section titled “RequiredAction”export interface RequiredAction { id: Ulid; arId: Ulid; title: string; dueAt: IsoTimestamp; href: string;}The AR-user dashboard’s “what’s on your plate” list. Production derives this set per request from open breaches awaiting AR action, MI returns due, file reviews to challenge, annual-review attestations.
AuditEvent
Section titled “AuditEvent”The demo carries a slim AuditEvent. Production extends it with the SHA-256 prev-hash chain that makes the log tamper-evident.
// Demoexport interface AuditEvent { id: Ulid; at: IsoTimestamp; actorName: string; actorRole: Role; action: string; subjectType: "ar" | "breach" | "review" | "annual-review" | "mi-return" | "tenant" | "user"; subjectId: Ulid;}
// Productionexport interface AuditEventProd { id: Ulid; tenantId: Ulid; at: IsoTimestamp; actorUserId: Ulid | null; // Null for system actors (anomaly worker, etc.) actorRole: Role; action: string; // e.g. "breach.notify-fca", "annual-review.sign-off" subjectType: "ar" | "breach" | "review" | "annual-review" | "mi-return" | "tenant" | "user"; subjectId: Ulid; ip: string | null; userAgent: string | null; prevHash: string; // SHA-256 hex of prior event's `hash` hash: string; // SHA-256 of canonicalised JSON of this row including prevHash}The chain is global per tenant. A nightly integrity job recomputes hash from the row contents and the previous row’s hash, alerting on mismatch. Retention is 10 years (see retention table in Persona and tenant model).
Tenant (production-only)
Section titled “Tenant (production-only)”export interface Tenant { id: Ulid; legalName: string; tradingName: string; frn: Frn; vertical: Vertical; rubric: RubricCode; brandHex: string; registeredOffice: PostalAddress; onboardedAt: IsoTimestamp; deletedAt: IsoTimestamp | null; createdAt: IsoTimestamp; updatedAt: IsoTimestamp;}In the demo, three Tenant rows are encoded as skins in lib/skins.ts. In production, a Tenant row exists per principal firm. vertical and rubric together determine the file-review rubric used (one rubric per vertical at v1; tunable per tenant in a later release).
User (production-only)
Section titled “User (production-only)”export interface User { id: Ulid; tenantId: Ulid; email: string; displayName: string; role: Role; arId: Ulid | null; // Set when role === "ar-user" status: "invited" | "active" | "suspended" | "off-boarded"; lastLoginAt: IsoTimestamp | null; createdAt: IsoTimestamp; updatedAt: IsoTimestamp; deletedAt: IsoTimestamp | null;}arId is the join key that scopes an AR-user to a single AR. Set on invite, never mutated; off-boarding tombstones the row.
Zod schemas
Section titled “Zod schemas”Validation lives in lib/schemas.ts (production). Demo skips Zod since fixtures are typed at compile time. Excerpts:
import { z } from "zod";
export const PenceSchema = z.number().int().nonnegative();export const FrnSchema = z.string().regex(/^\d{6,7}$/);export const UlidSchema = z.string().regex(/^[0-9A-HJKMNP-TV-Z]{26}$/);export const IsoTimestampSchema = z.string().datetime({ offset: true });export const PostcodeSchema = z.string().regex(/^[A-Z]{1,2}\d[A-Z\d]?\s*\d[A-Z]{2}$/i);
export const PostalAddressSchema = z.object({ line1: z.string().min(1).max(120), line2: z.string().max(120).nullable(), city: z.string().min(1).max(80), postcode: PostcodeSchema, country: z.literal("GB"),});
export const PermissionSchema = z.object({ code: z.string().regex(/^[A-Z]+\.[A-Z_]+$/), label: z.string().min(1).max(120), grantedOn: IsoTimestampSchema, revokedOn: IsoTimestampSchema.nullable(),});
export const BreachReportSubmissionSchema = z.object({ arId: UlidSchema, title: z.string().min(4).max(200), description: z.string().min(20).max(10000), category: z.enum([ "conduct", "financial-crime", "data-protection", "complaints-handling", "advice-suitability", "disclosure", "training-competence", "other", ]), severity: z.enum(["minor", "moderate", "material", "significant"]), customerImpact: z.enum(["none", "potential", "actual-low", "actual-high"]), awareAt: IsoTimestampSchema, rootCauseTaxonomy: z.array(z.string().min(2).max(64)).max(8),});
export const MIReturnDraftSchema = z.object({ arId: UlidSchema, period: z.object({ year: z.number().int().min(2024).max(2100), quarter: z.union([z.literal(1), z.literal(2), z.literal(3), z.literal(4)]), }), metrics: z.object({ newBusinessVolumeGBP: PenceSchema, newBusinessCount: z.number().int().nonnegative(), complaintsReceived: z.number().int().nonnegative(), complaintsUpheld: z.number().int().nonnegative(), breachesSelfReported: z.number().int().nonnegative(), conductEventsLogged: z.number().int().nonnegative(), cancellations: z.number().int().nonnegative(), }).refine((m) => m.complaintsUpheld <= m.complaintsReceived, { message: "complaintsUpheld cannot exceed complaintsReceived", path: ["complaintsUpheld"], }),});
export const FileReviewFindingSchema = z.object({ itemCode: z.string().min(2).max(40), itemLabel: z.string().min(1).max(200), outcome: z.enum(["pass", "advisory", "fail", "n/a"]), evidence: z.string().max(4000), remediation: z.string().max(2000).nullable(),});
export const FileReviewCompleteSchema = z.object({ findings: z.array(FileReviewFindingSchema).min(1), notes: z.string().max(4000), rootCauseTaxonomy: z.array(z.string().min(2).max(64)).max(8),});Relationships
Section titled “Relationships”erDiagram Tenant ||--o{ AppointedRep : "has" Tenant ||--o{ User : "has" Tenant ||--o{ AuditEvent : "logs" AppointedRep ||--o{ BreachReport : "subject of" AppointedRep ||--o{ FileReview : "subject of" AppointedRep ||--o{ MIReturn : "subject of" AppointedRep ||--o{ AnnualReview : "subject of" AppointedRep ||--o{ ConductEvent : "subject of" AppointedRep ||--o| User : "ar-user via arId" AnnualReview }o--o{ BreachReport : "summary refs" AnnualReview }o--o{ FileReview : "summary refs" AnnualReview }o--o{ MIReturn : "refs" ConductEvent ||--o{ Attachment : "has"Row-level security
Section titled “Row-level security”Every tenant-scoped table has a Postgres RLS policy:
ALTER TABLE appointed_reps ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON appointed_reps USING (tenant_id = current_setting('app.tenant_id')::uuid) WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid);Session middleware sets app.tenant_id per connection from the validated session row. Application code never selects without the GUC set; a migration check refuses to deploy if any tenant-scoped table lacks RLS. Full discussion in Persona and tenant model.