feat(backend): implement data retention policies - #111
Open
Darkdruce wants to merge 1 commit into
Open
Conversation
Adds the data retention subsystem: policy registry, archival to cold storage, purging, in-place anonymization, a tamper-evident compliance audit trail, archive retrieval, and health monitoring. The sweep's ordering is the safety argument: every step is durable before the destructive one runs, so a crash at any point leaves data duplicated (recoverable) rather than lost. Archived objects are read back and checksum-verified before any source row is deleted, and PII is scrubbed before the archived copy is written. Compliance log entries are hash-chained and the table carries a trigger rejecting UPDATE and DELETE, so tampering is both detectable and hard. Monitoring reports the state of the world rather than the last run: a sweep that returns clean results is worthless if it stopped being scheduled. Backlog counts are capped so the health check does not get slower exactly as the situation gets worse.
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.
Closes #98
Implements the data retention subsystem in
backend/database/retention/, covering all six items in the issue.What's here
archiver.ts— batched sweep to cold storagearchiver.ts— verified delete, keyset-paginatedcompliance-log.ts— hash-chained append-only audit trailrestore.ts,manifest.ts— searchable manifest + checksum-verified restoreanonymizer.ts— keyed HMAC pseudonymization / redactionmonitor.ts— health snapshot, severity-ranked alerts, Prometheus gaugesStorage backends are pluggable (
ColdStorageBackend); a filesystem implementation is included for local/test use and an S3 Glacier one for production.Design notes worth reviewing
The sweep's ordering is the safety argument. Every step is durable before the destructive one runs, so a crash at any point leaves data duplicated (recoverable) rather than lost. Concretely: archive → read back → verify checksum and row count → record manifest → delete → verify deletion. An object that cannot be retrieved and verified is not an archive, and deleting against it would be data loss dressed up as compliance.
PII never reaches cold storage. Rows are scrubbed before serialization, not after. Stellar addresses are pseudonymized with a keyed HMAC rather than a plain digest — the address space is small enough that an unkeyed SHA-256 is brute-forceable, which would leave the "anonymized" data still personal under GDPR.
The audit trail is defensible, not just present. Entries commit to their predecessor's hash, so an edit or deletion invalidates every hash after it, and an auditor can verify independently. The table additionally carries a trigger rejecting
UPDATE/DELETE— without it, an operator with table privileges could rewrite history and re-chain the hashes.Policies are the injection surface. Table and column names are interpolated into SQL where bind parameters aren't allowed, so
validatePolicyrejects anything that isn't a bare lowercase identifier.Monitoring reports state, not the last run. A sweep returning clean results every time is worthless if it stopped being scheduled a month ago, and only a standing check notices that. Backlog counts are capped at 100k via a
LIMITinside a subquery so the health check doesn't get slower exactly as the situation gets worse. Stale-sweep severity follows the regime: on a GDPR class it's a compliance breach, not a backlog.Tests
33 tests in
backend/tests/database/retention.test.ts, all passing. They run against an in-memory fakepgpool rather than mocks — the safety argument is about the ordering of reads, writes and deletes, and only something stateful can tell whether rows were still present when the delete ran.Coverage is aimed at the failures that are silent and expensive: deleting against a corrupted archive, PII surviving into cold storage, a tampered audit trail that still verifies, a stalled sweep nothing alerts on.
The tests caught a real bug during development: deletion was bounded by
timestamp < rangeEnd, butrangeEndis the max timestamp in the batch — so the newest row of every batch was archived and then never deleted, sitting duplicated in both tiers permanently since the keyset cursor had already moved past it. Fixed to<=, which preserves the original intent of sparing a row updated mid-flight.Schema
Migration
1724000000000_add-retention-tables.jsand the matchingdb/schema.sqlblock. The integration suite bootstraps fromschema.sql, so changing one without the other would let tests pass against a shape production never has — the constraints are duplicated in both deliberately.Note for reviewers
backend/tsconfig.jsonhasinclude: ["src"], but there is nobackend/src/directory, andjest.config.jsonsetsdiagnostics: false. Nothing inbackend/is currently typechecked, including this module. I typechecked it explicitly (tsc --strict, clean) and that surfaced two latent type errors in code I'd otherwise have shipped unchecked. Wideningincludewould pull in every root-levelbackend/*.tsfile at once, so I've left it alone as a separate decision — but it's worth making.