Skip to content

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.

lib/types.ts
export type Ulid = string;
export type IsoTimestamp = string;
export type Frn = string; // FCA Firm Reference Number, 6-7 digits
export type Pence = number; // Integer
export 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).

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.

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.

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).

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).

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.

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.

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.

export interface Attachment {
id: Ulid;
filename: string;
mimeType: string;
bytes: number;
storageKey: string; // Reference into S3-compatible store
}
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.

The demo carries a slim AuditEvent. Production extends it with the SHA-256 prev-hash chain that makes the log tamper-evident.

// Demo
export interface AuditEvent {
id: Ulid;
at: IsoTimestamp;
actorName: string;
actorRole: Role;
action: string;
subjectType: "ar" | "breach" | "review" | "annual-review" | "mi-return" | "tenant" | "user";
subjectId: Ulid;
}
// Production
export 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).

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).

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.

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),
});
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"

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.