Threads · 05 / 18 · Sep 7, 2026 · Apache-2.0

Classify every column, or fail the build

A registry each module reports into cannot see the module that forgot to report — which is the only one you needed it to find. It answers "is everything I was told about classified?", and reports green while an address walks out of an erasure request. A check whose universe is supplied by the thing being checked cannot report an omission.

The check is green while it leaks

Two modules. The CRM one registers its columns; the billing one, written months later by somebody who never read the first, does not.

expect(everythingClassified()).toBe(true);
expect(known().some((c) => c.column === "billing_email")).toBe(false);

Green, and blind — in the same breath. This is not a bug in the registry: a module that says nothing is, to a registry, a module with nothing to say. The two are indistinguishable, and one of them is the leak.

Then somebody asks to be erased:

expect(columnsToErase()).toEqual(["clients.email", "clients.phone"]);

Two columns cleared, an email address left behind, every check green while it happened. Nobody finds out until somebody writes to that address.

The gate reads the schema, not the registry

expect(unclassified(SCHEMA)).toEqual([{ file: "billing.ts", column: "billing_email" }]);

Same code, same two modules, opposite answer — because the question is asked of every column that exists rather than every column somebody mentioned. Default deny: an unclassified column is a failure, not an unknown.

And it goes red on the commit that adds the column, in front of the person adding it, while they still remember why they added it. A runtime check reports months later, to somebody else, about a decision nobody can reconstruct.

The difference, stated once

expect(schemaHas.size).toBeGreaterThan(registryKnows.size);

The registry's universe is a subset of the schema's, short by exactly the amount somebody forgot.

A check whose universe is supplied by the thing being checked cannot report an omission.

That sentence is the whole repository, and it is not only about PII. It applies to any registry of modules, any list of "everything we handle", any inventory a system builds by asking its own parts to volunteer.

Where the classification goes

Next to the column, in the schema, never in a document beside it:

email: text("email"),                 // @pii yes · reason=contact
billingEmail: text("billing_email"),  // ← the build stops here

A separate registry file drifts, because the column and its classification are edited by different people on different days. An annotation on the line is edited by whoever moves the line.

The classification is also not retroactive in practice. Encrypt-per-subject, retention windows, export and erasure all read it, and a column that shipped unclassified has already been written to disk in the clear by the time anybody notices.

What to carry over

The gate here is thirty lines of regex over a demo schema; a real one walks the AST of the ORM's table definitions. What has to survive the port is the shape:

An exception is fine, as long as it is a named exception in the gate — with the reason, in the file the gate reads. Then adding one is a diff somebody reviews.

Where this comes from

niiko handles data belonging to other people's customers, under an erasure obligation it has to be able to execute. The classification decides which columns are encrypted per subject and which ones an erasure clears, so a column the gate never saw is a column the erasure never reaches.

Related: crypto-shredding-erasure, which is what runs once every column has an answer.

License

Apache-2.0 — see LICENSE. This is a demonstration, not a package. Copy what you need.


Built by Vorluno — a software studio from Panamá.

// next threadHuman approval is not a defence; freezing the arguments is