An audit is a request for documents. The controls on the previous pages
produce them as a side effect of running; this page is about asking for them
— the sheet a model prints about itself, the reports the scanners produce, the
tables the trails write — and about what to hand over for which question.The compliance sheet#
One call, per model, that says what it is held to, what it holds, how children
are gated, where it lives, and every finding:import { complianceSheet, renderComplianceSheet } from 'ts-prorm-orm';
const sheet = complianceSheet(Patient); // structured
const markdown = renderComplianceSheet(Patient); // the same, rendered
interface ComplianceSheet {
model: string;
asOf: string; // the registry's date
jurisdictions: Array<{ code: string; name: string; law: string[] }>;
resolved: {
digitalConsentAge: number;
minimumAge?: number;
requiresBasis: 'always' | 'special-only' | 'never';
lawfulBases: LawfulBasis[];
specialCategories: PersonalCategory[];
prohibited: PersonalCategory[];
breachNotificationHours?: number;
requestDeadlineDays?: number;
residency: Residency;
rights: SubjectRight[];
};
fields: Array<PersonalField & { name: string; special: boolean; ceiling?: Duration }>;
ageGate: { field?: string; consentField?: string; threshold: number } | null;
region?: string;
findings: Finding[];
}
The rendered form is what you paste into a README, a data-protection
register, or the response to a questionnaire:A model that has no compliance applied throws from complianceSheet()
rather than printing an empty sheet — an empty sheet would read as "nothing
to declare", which is not what an undeclared model means.Every model at once#
for (const [, Model] of prorm.getModels()) {
if (complianceOf(Model)) register.push(renderComplianceSheet(Model));
}
The models with nothing applied are the finding: a register that lists nine
models when the schema has twelve has three questions in it.A register worth handing over is dated, tied to a commit, and says which models
it left out. A script that writes one directory per run:import fs from 'node:fs';
import path from 'node:path';
import { complianceOf, renderComplianceSheet } from 'ts-prorm-orm';
import { gitInfo } from 'ts-prorm-orm/compliance/audit';
const { commit, dirty } = gitInfo(process.cwd());
const dir = path.join('register', new Date().toISOString().slice(0, 10));
fs.mkdirSync(dir, { recursive: true });
const undeclared: string[] = [];
for (const [name, Model] of prorm.getModels()) {
if (!complianceOf(Model)) {
undeclared.push(name);
continue;
}
const header = `<!-- generated from ${commit ?? 'no commit'}${dirty ? ' (uncommitted changes)' : ''} -->\n`;
fs.writeFileSync(path.join(dir, `${name}.md`), header + renderComplianceSheet(Model));
}
fs.writeFileSync(path.join(dir, 'UNDECLARED.txt'), undeclared.join('\n') + '\n');
register/
2026-09-26/
Patient.md # one sheet per declared model
Appointment.md
UNDECLARED.txt # the models nobody has declared yet: the questions
[!WARNING]
dirty: true means the sheets describe code that is not in any commit.
Regenerate from a clean checkout before the register goes to anyone outside
the team.
What the scanners say#
Sensitive data discovery reports what looks personal, whether or not it
was declared:SensitiveDataDiscovery.generateComplianceReport(prorm);
// per model: the fields matched, the pattern, the confidence, the recommendation,
// and the frameworks the pattern is tagged for
Diff it against the sheets. A column the scanner flags that no @Personal
declares is either a false positive to suppress or a declaration you owe.Classification answers the narrower question, per model:getPIIFields(Patient); getPHIFields(Patient); getPCIFields(Patient); getConfidentialFields(Patient);
What the trails hold#
| Question | Table | Written by |
|---|
| who changed this row, when, from where | the audit log (ComplianceAuditLogs by default) | ComplianceAuditTrail |
| did this person consent, to what, on what basis | consent_records / consent_versions | ConsentRecord |
| what did we send abroad, and under what mechanism | cross_border_transfers | CrossBorderLog |
| where did this value come from | the lineage tables | DataLineage |
| was this record altered after freezing | the WORM tamper log | WORMStorage |
| what did we assess before starting | pia_assessments | PrivacyImpactAssessment |
| which requests are open, and which are late | your DSAR table, driven by DSARWorkflow | you |
| what was purged, when | the retention purge's return value — log it | you |
[!NOTE]
The audit trail records writes (CREATE, UPDATE, DELETE), not reads.
"Who has accessed this record?" is answered by the trail only for changes.
For reads, you need the database's own logging (for example pgaudit,
which the DB-AUDITLOG probe looks for) or the platform's query log.
The two questions auditors ask most, "show me this record's history" and
"show me a deletion", against a trail enabled on Patient:import { ComplianceAuditTrail } from 'ts-prorm-orm';
const trail = ComplianceAuditTrail.enable(prorm, Patient, {
getUserId: () => currentUser()?.id,
getIpAddress: () => currentRequest()?.ip,
});
// Every change to patient 42 in the review period, newest first.
const history = await trail.getModelHistory(42, {
startDate: new Date('2026-07-01'),
endDate: new Date('2026-10-01'),
});
// The deletion entry: who, when, from where, and the row as it stood.
const deletion = history.find((e) => e.action === 'DELETE');
On the managed platform, add: the database's events feed (every key
issued and revoked, every rotation, every operation), the policy firings
(what fired, why, what was done), and the watermark trace for any
credential. See Platform policies.What to hand over, by question#
| The auditor asks | Give them |
|---|
| "What personal data do you process, and on what basis?" | the sheets — Art. 30 of GDPR is essentially this table |
| "How do you handle a subject access request within 30 days?" | the DSAR workflow's states, the export bundle, the open-requests query |
| "Show me a deletion." | the erasure result, the audit entry for it, and the backup re-purge procedure |
| "Who has accessed this record?" | the audit log filtered by row |
| "Is this encrypted at rest?" | the enc:v1: prefix in the raw column, and @Security / @Encrypt on the model |
| "Do you transfer data outside the EU?" | cross_border_transfers, the region table, the transfer-outside finding on the sheet |
| "What happens when someone guesses at a database?" | the auth.failed.* policy assignment and its firings |
| "How do you know your backups restore?" | the platform's restore operations, or your own verification register |
| "Have you assessed this processing?" | the approved PIA linked to the model |
| "How do you handle children's data?" | the age gate on the sheet, the parental-consent flow, the family.* policies |
Keeping evidence honest#
Two habits make all of this hold up.Date everything. The sheet carries asOf; your register should carry
the date you generated it and the commit it was generated from. A sheet from
a schema that has since changed is a historical document, not a current one.Do not edit the output. If the sheet has a finding you disagree with,
fix the declaration or the registry and regenerate. A hand-edited sheet is
worse than one with a finding on it — the finding is at least true. Modified at 2026-10-04 00:40:16