Skip to content

feat: take the data out, and get rid of it for good - #250

Merged
AKogut merged 1 commit into
mainfrom
feat/data-governance
Aug 5, 2026
Merged

feat: take the data out, and get rid of it for good#250
AKogut merged 1 commit into
mainfrom
feat/data-governance

Conversation

@AKogut

@AKogut AKogut commented Aug 5, 2026

Copy link
Copy Markdown
Owner

Closes #69.

Three of the four scope items; retention was already built and is documented here for the first time.

Export

GET /v1/export (read scope) and Data → Download export in the dashboard both stream the same gzipped NDJSON archive.

{"type":"manifest","version":1,"projectId":"…","datasets":[…]}
{"type":"row","dataset":"run","data":{…}}
{"type":"artifact","key":"org/…/shot.png","size":18244,…}
{"type":"summary","rows":421,"artifacts":0,"counts":{…}}

The summary line is the point. A gzip stream cut off halfway is still a valid gzip, so a truncated download and a small project look identical. The trailing count is what tells them apart.

Paging is keyset, not offset — the last page of a million executions would otherwise scan the whole table. Composite keys compare as a row ((a, b) > ($2, $3)), so the second column is not silently dropped.

What the archive does not contain

ingest_token.token_hash, notification_channel.secret, project.badge_token.

An export is served to anyone holding a read credential. Shipping a token hash or a webhook signing key would turn "download my data" into "take the project's keys" — and turn a read-only credential into one that can write.

A guard reads schema.prisma and fails if a project-scoped table is neither exported nor listed in EXPORT_EXCLUDED with a reason, or if a column matching secret|token|password|credential was never classified as either omitted or reviewed-and-fine. Two such columns exist and are genuinely not credentials (error_signature.tokens, rca_report.token_cost); both had to be named explicitly. There is a second test that re-runs the check with one omission removed and asserts it trips, so the silence means "decided", not "stopped looking".

Deletion

Owner-only, slug typed to confirm, both checked on the server — a confirmation the client enforces is one that anybody bypassing the client never has to give.

Ordering is the substance here:

  1. Tokens are revoked when the request is made, not when it runs. The sweep is up to a minute away and CI does not pause for a deletion request; an erasure racing an ingest never converges.
  2. Runs in the worker, not the request. Deleting a large project outlives an HTTP timeout, and a request that gave up halfway would leave a tenant half erased with nothing recording how far it got.
  3. Artifacts before rows. Once the rows are gone nothing is left that says which object keys belonged to the tenant, so a bucket failure after the delete would strand them permanently. The prefix is on the request record, so a crash before the delete is only a retry — deleteMany throughout, so the retry does not fail on what the first pass already did.

"Verifiably"

The acceptance criterion asks for verifiable, so the erasure checks its own work rather than trusting that a delete returned without an error.

Every table carrying a project_id is counted again — asked of information_schema, not of a list in the source. A hand-maintained list is correct the day it is written and wrong the first time someone adds a model, and a table missing from it is one whose rows survive an erasure silently. A bare projectId column with no cascading foreign key would not be deleted at all; this is what catches that. Anything left is recorded as residue and the request is marked failed.

Org erasure counts by org_id, which is complete only because every project-scoped table also carries one — so there is a test asserting exactly that.

The audit row has no foreign key to the tenant it describes. A cascade would delete the evidence along with the data, so subject and artifactPrefix are copied in at request time; afterwards there is nothing left to read them from.

Ran it, not just tested it

app.inject is not a socket, so I booted the API and downloaded over HTTP:

content-type: application/gzip
content-disposition: attachment; filename="flakemetry-web-2026-08-05.ndjson.gz"
Transfer-Encoding: chunked

423 lines for a summary claiming 421 rows — manifest plus rows plus summary, exactly. grep for token_hash, secret and badge_token: nothing. Then queued a real erasure and ran the actual worker binary:

worker: erased project "Acme Web" (web): 422 row(s), 0 artifact(s)

Afterwards: 0 projects, 0 executions, 0 runs, 0 tokens, 2 audit rows — the export record and the erasure record, both intact.

The residue check was proved non-vacuous by making eraseTarget delete only executions: it reported 19 tables of residue instead of passing.

Deliberately not built

User deletion. A user is a person who may belong to several workspaces; removing one of them is not consent to remove the person. Their sessions, accounts and RCA feedback already cascade from the user record. Wiring a button to that belongs with a decision about what happens to their memberships elsewhere, not tacked onto tenant deletion. Called out in the docs rather than left as a gap someone discovers.

Artifact bytes in the archive. Base64 inside NDJSON inflates a multi-gigabyte export by a third. The inventory carries every key, size and timestamp, which is what you need to pull the bytes from your own bucket.

62/62 turbo tasks green.

Exports a project as a gzipped NDJSON archive, hard-deletes a project or a
workspace including its object-store artifacts, and records both.

The archive streams: a manifest line, one line per row, an artifact inventory,
then a summary saying how much the file should contain. Without that trailing
line a download cut off halfway is indistinguishable from a small project, since
both are valid gzip. Paging is keyset, not offset, so the last page of a million
executions does not scan the table.

Token hashes, webhook signing secrets and the badge token are left out. An
export is served on a read credential, and shipping any of them would turn
"download my data" into "take the project's keys". A test reads schema.prisma
and fails if a project-scoped table is neither exported nor explicitly excluded,
or if a credential-shaped column was never classified either way.

Deletion revokes the ingest tokens when the request is made rather than when it
runs — the sweep is a minute away and CI does not pause for a deletion request.
Artifacts go before rows, because once the rows are gone nothing says which keys
belonged to the tenant. Then the erasure verifies itself: every table carrying a
project id, asked of the live database rather than of a list in the source, is
counted again and the bucket prefix listed again. Anything left is recorded as
residue and the request fails loudly.

The audit row has no foreign key to the tenant it describes, so a cascade cannot
delete the evidence along with the data.

Closes #69
@AKogut
AKogut merged commit 195d7fa into main Aug 5, 2026
8 checks passed
@AKogut
AKogut deleted the feat/data-governance branch August 5, 2026 14:25
AKogut added a commit that referenced this pull request Aug 5, 2026
Four separate places where the README described an older product.

**Status line** said "M4–M7 (platform, scale, launch, actionability) are
open". M7 is closed — four features shipped in it.

**Capability table** stopped at AI RCA. Missing from the front page:
flake bisect, cost of flakiness, tracker issues, health badges, the
public read API, signed webhooks, the CLI, and data export/deletion.
Someone evaluating the project was reading a list about half the size of
what exists.

**Roadmap table** had no M7 row at all, and no indication which
milestones were done. Now carries a status column, and says plainly why
M7 landed ahead of M4–M6 — a reader who sees milestones completed out of
order deserves the reason rather than the puzzle.

**Monorepo layout** listed nine packages out of sixteen.
`vitest-reporter` and `jest-reporter` were absent even though the setup
section thirty lines above documents both, and `queries`, `notify` and
`storage` were missing entirely.

Every claim added here was checked against the code, not against the
issue titles: the gate action's `strictness` levels, the tracker closing
issues on recovery (`kind: 'close'` in
`packages/queries/src/tracker.ts`), and the export/retention/erasure
paths merged in #250.

Related: #178 has also been updated — its auto-generated API reference
scope item shipped in #246, so only versioned docs remain, and the title
now says so.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Data export API + retention & deletion (GDPR/DPA)

1 participant