Data schema validation rules
Where each rule lives: schema (Prisma types, enums, uniques), database (triggers, indexes), application (zod schemas in lib/validation/schemas.ts, roster/csv helpers), tests. See docs/schema.md for the data dictionary. Last updated 2026-09-02.
1. Cross-cutting
| Rule | Layer |
|---|---|
Ids are cuid/uuid strings; externalId is [A-Za-z0-9._:-]{1,64} and unique per tenant | schema (@@unique([tenantId, externalId])) + zod ExternalId |
| Emails lower-cased, RFC-shaped, ≤254; unique when present | zod Email + User.email @unique |
Usernames ^[a-z0-9][a-z0-9._-]{2,31}$, globally unique | zod Username + User.username @unique |
| PINs 4–6 digits, never start with 0 when generated; stored bcrypt | zod Pin, generatePin() |
| Passwords ≥ 8 chars; stored bcrypt | zod Password |
Validity windows: startDate ≤ now < endDate, endDate null = open, endDate > startDate | isActiveOn(), zod refinements, CSV validator |
| Enumerations are Prisma enums; free strings only where the vocabulary is open and documented | schema |
Money is integer SGD with Sgd suffix | schema |
containsPii/piiSubjectIds are never set by hand — the audited client computes them | harness |
2. School user management
- SchoolMembership:
(userId, schoolId)unique; role enum; admin cannot end/demote self (action rule). - Student: required
externalId, firstName, dateOfBirth, yearGroup, className;dateOfBirthvalid date ≤ today; at most one openClassEnrolment(roster helper closes others);className/yearGroupmirror the active enrolment. - Class:
(tenantId, name, academicYear)unique;academicYearYYYY/YY;startDaterequired. - Group:
(tenantId, name)unique. - CSV import: header must map all required columns; per-line errors for bad dates, duplicate ids in file, bad username/pin, end ≤ start, pin without username; nothing written on any error (dry run).
3. Identity
- SsoConfig: provider enum;
allowedDomainsare^[a-z0-9.-]+\.[a-z]{2,}$; status transitionsDRAFT → VERIFIEDonly by a successful test sign-in,VERIFIED ↔ ACTIVEby admin; changing provider resets toDRAFT. - School.subscriptionStatus enum +
subscriptionEndsAt; SSO for non-admins requiresACTIVE|TRIALand unexpired. - SsoIdentity:
(provider, subject)unique.
4. Parent module
GuardianRelationship:(tenantId, guardianExternalId, childExternalId)unique;parentalResponsibilitydefault false;contactSuppresseddefault true.TenantParentConfig: thresholds nullable, no defaults;ageOfTransitionnullable.GuardianRequest: kind/status enums; body 1–5000 chars.
5. Audit platform
- Event id unique (idempotent ingest);
(chain, chainSeq)unique;hash= SHA-256(prevHash ‖ canonical fields); PII change values encrypted (enc:prefix) under the first subject's key. - HMAC signature over
timestamp.body; skew ≤ 5 min; batch ≤ 1000 events. - No update/delete paths on
AuditRecord;SubjectKey.keyEncis the only field ever nulled (shred).
6. Where validation is *not* yet enforced (tech debt)
Legacy marketplace actions (admin.ts, credits.ts, school.ts, startup.ts, trips.ts) coerce FormData by hand; they are allow-listed in the harness test and must adopt schemas when touched.
