feat(audit): add auditable data lifecycle and archive policy - #148
Merged
3m1n3nc3 merged 1 commit intoAug 24, 2026
Merged
Conversation
7 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Implements #124: soft-delete, archive, retention, erasure, and immutable audit
behaviour for sensitive data.
Full policy and rationale:
docs/DATA_LIFECYCLE.md.What landed
Lifecycle matrix — all 37 models in
schema.prismaclassified as MUTABLE,ARCHIVABLE, DELETABLE, or IMMUTABLE, each with a data category, retention window
and anchor column, erasure behaviour, audit requirement, and a written rationale.
src/audit/classification.tsis the machine-readable source of truth;tests/audit/classification.test.tsparses the schema and fails on anyunclassified model, so the two cannot drift.
Retention by category: money and consent 7y, credentials indefinite, security
events bounded (30–90d for auth material, 7y for audit events), content archived
then purged at 365d, operational journals 30–90d, export artifacts 7d — the
shortest in the schema, because that row is a full personal-data dump.
Immutable audit events — new
audit_eventstable recording actor(type/id/role), action, target (type/id), record class, reason, request id,
correlation id, source, redacted metadata, keyed IP hash, and UA family.
Enforcement is in the database, not convention:
UPDATErejected unconditionally — no escape hatchDELETErejected unless the transaction setlearnault.audit_purge, whichonly
purgeExpired()does, and which deletes by timestamp so it cannottarget one inconvenient event
TRUNCATErejected by a statement-level trigger, since it bypasses row triggersSET LOCAL(notSET) so the permission cannot leak to a later request on apooled connection
No FK to
User, deliberately:Cascadewould let erasure destroy the trail andSetNullwould mutate an immutable row.Redaction on write — audit rows can't be scrubbed later, so metadata is
filtered on the way in. Two independent passes: key names, and value shapes
(Stellar seed/public key, JWT, bearer credential, email, E.164, IPv4, 40+ char
hex blob, PEM header) so a secret nested under an innocuous key is still caught.
Structural caps bound depth/breadth/length/size. Redacted paths are recorded in
_redactedso a reader isn't misled.Over-redaction is treated as its own failure —
statusCode,failureCode,referralCode,amountStroopsstay readable.tois deliberately allowed: it'sthe standard key for a status transition, and the value-level email pattern
catches an actual recipient anyway.
IP hashing uses HMAC, not a bare digest — the IPv4 space is small enough to
enumerate. If
AUDIT_IP_HASH_SECRETis unset in production the hash is omittedentirely rather than falling back to a value published in this repo.
Audited mutation helper —
auditedMutationcommits the change and its auditevent in one transaction. A failed audit write rolls the mutation back, which is
the opposite of
auditEventService.record()(fire-and-forget, for standaloneevents like a failed login). ADMIN actors must supply a reason, checked before the
transaction opens.
auditedArchive/auditedRestorewrap the archive patch thesame way and refuse models the matrix doesn't classify as ARCHIVABLE.
Archived rows excluded by default — a Prisma client extension injects
archivedAt: nullintofindFirst/findMany/count/aggregate/groupByonarchivable models, applied once in
src/config/database.tsso the guarantee holdsfor code that's never heard of this module. Two documented carve-outs:
findUnique(a silent filter turns a found row intonulland reads as "deleted"to code holding the id — use
assertActive) and writes (archive/restore must seetheir own row). Opting out via
includeArchived()is visible at the call site.archivedAt/archivedById/archivedReasonadded toLearnerProfile,Module,Avatar,ReferralCode,WebhookEndpoint, each with aCHECKconstraint so anarchived row always states why — because "archive" lands from several call sites
and a reason-less archive is indistinguishable from an accident months later.
Verification
pnpm test— 1073 passed, 3 skipped, 59 files. 222 of those are new.pnpm lint— clean.tsc --noEmit— no new errors. (Four files have pre-existing errors from theearlier
reward/amount/bonusAmount→*Stroopsrename, unrelated to thischange and confirmed present on
main.)prisma validate— schema valid; client generates.Test coverage maps to the acceptance criteria: immutability (no mutating API
surface, purge uses the session variable, DB-level UPDATE/DELETE/TRUNCATE
rejection), visibility (default exclusion and every carve-out), attribution
(actor distinct from target, transaction atomicity, rollback on audit failure,
ADMIN-reason policy), and redaction (key and value deny-lists, caps, IP hashing,
UA coarsening, and explicit over-redaction guards).
Not yet verified — needs a reviewer with a database
No Postgres was reachable in this environment (Docker daemon down), so:
migrate deployoutput is not attached. The migration SQL is unexecuted.Please run it and confirm before merge.
tests/integration/audit-immutability.test.tshas not actually run. Itskips without a reachable DB.
Worth knowing about that test:
tests/globalSetup.tsprepares schemas withprisma db push, which creates tables fromschema.prismaand never executesmigration SQL — so the triggers don't exist in a test database by default. The
test therefore extracts the immutability DDL from the shipped migration file and
applies it itself, which means it verifies the artifact that actually ships rather
than a copy. The migration's trigger DDL uses
DROP ... IF EXISTSso it isre-appliable. CI has no Postgres service either, so this test will skip there too
until one is added.
Reviewer notes
src/config/database.tscasts the extended client back toPrismaClient. Theextension only rewrites a query's
where— it adds no methods and changes noresult shapes — and widening every injection site to a union of both client
types makes the overloaded
$transactionsignature stop resolving.tests/services/webhook.service.spec.tsgained a$extends()stub on its mockPrismaClient, since the real client configuration now calls it.audit_logsis now marked legacy/superseded. It still works and existingwriters are untouched; new code should write
audit_events. Migrating thosecall sites is deliberately out of scope here.
auditedMutationin this PR — theprimitives and policy land first, so Phase 1 profile/account lifecycle work can
build on them without a large simultaneous refactor.
Closes #124