Verification
Masking is a claim. Verification is a check.
After the masking rules run, the engine scans the candidate for data that still looks like a person: addresses, card numbers, national identifiers, names in free text. If it finds anything, nothing is published. If it finds nothing, it signs a statement of what it scanned and what it found, and that statement is what makes the version branchable.
copy ──> mask ──> scan ──> attestation ──> golden │ └── anything found: nothing is publishedWhat the scan reads, and what it says it did not
Section titled “What the scan reads, and what it says it did not”Strings, JSON and XML are read as they are. Arrays, enums and extension types
are read through their text form. A bytea column is decoded as UTF-8 where
it decodes, because a secret pasted into a binary column is text in a binary
coat. Numbers, times, booleans and identifiers the database generates are not
read, because their text form cannot carry a sentence somebody typed.
Anything else is listed as not readable by the scanner, with the type that made it so:
✓ clean 231 columns across 55 tables, 6111 rows sampled ! public.provider_keys.ciphertext: 4 of 4 sampled values are binary rather than text and could not be read (masked by its rule) 0 columns copied unchanged with no rule.That line is the difference between “the scan found nothing” and “the scan
found nothing in what it opened”. Until it existed the scan read six text
types and nothing else, said clean, and a bytea holding a sealed private key
was neither read, nor skipped, nor counted. af mask plan on the same database
listed it as copied unchanged. Two instruments, one database, opposite answers,
and the one that said clean was the one that gated publication.
The scan cannot fail every column it cannot read; an environment with no enum columns is no environment. It fails the narrow case where three facts line up: it cannot read the column, no masking rule covers it, and the name says what it holds.
AF-MSK-013 Verification could not read public.sso_connection_secrets.sp_private_key(bytea), no masking rule covers it, and its name says it holds a secret. Next: Give public.sso_connection_secrets.sp_private_key a rule in masking.yaml, nullify or hash_hex, and refresh the golden.The words are secret, key, token, private, ciphertext, password and
credential, in the table name or the column name. A rule on the column, any
rule, turns the failure into a note.
Third party identifiers
Section titled “Third party identifiers”The detectors know Stripe’s object identifier families as well as its secret
keys: cus_, sub_, in_, pm_, price_ and the rest, a prefix at the
start of a token followed by a body of at least twelve letters and digits
carrying a digit and a capital. A column of real customer ids trips it; a
column masked with prefixed_id does not, because the masked body is lowercase
hex. A Stripe identifier is not a secret, and it is exactly the kind of value
the scan exists to catch: one that says which real customer a row belongs to,
the same in every environment.
Columns copied unchanged
Section titled “Columns copied unchanged”The scan does not know the rules. The command that runs it does, and it hands
the scan the list of columns masking copied unchanged because no rule covered
them. The scan carries that list into its report, so the attestation records
the count and the names, af golden list shows the count beside verified,
and inspect_goldens returns it. A verified golden with 145 of these is a
different thing from one with none, and the listing used to say verified
about both.
Why the check is separate from the rules
Section titled “Why the check is separate from the rules”Because the rules are written by people. A column added last month has no rule,
a rule can name the wrong column, and a notes field can hold an address
somebody pasted into it. A masking pass that ran successfully proves the rules
ran, not that the data is safe.
Verification is the part that can say no.
Nothing branches an unverified golden
Section titled “Nothing branches an unverified golden”AF-MSK-001 The golden gv_20260826120000_a1b2c3d4 has no valid verificationattestation and cannot be branched. Next: Run 'af golden verify gv_...'; a golden is branchable only once verification has passed.This is enforced in code rather than in a checklist. It is the product’s central promise: an environment cannot contain unmasked production data, because the only thing an environment can branch is a golden, and a golden is not a golden until the scan passed.
Where it is enforced differs by provider, and the difference is worth
knowing. Neon, Supabase and Database Lab check the attestation at branch time
and refuse with AF-MSK-001. The Docker provider, which is the default on a
laptop, refuses earlier instead: a refresh whose verification fails never
commits an image, so there is no unverified golden in existence to branch. That
is the stronger place to refuse, and it is why the conformance behaviour named
below passes for it.
It is not equivalent, and this page used to say it was. Two things follow from the Docker provider treating the existence of an image as the verification, and a reader relying on this page should have both:
- A golden the provider lists is reported as verified because the image is there, not because anything re-read the attestation.
- Re-running
af golden verifyon a published golden and having it FAIL does not stop that golden being branched again, because nothing marks it unverified afterwards. On the other three providers the next branch is refused.
The conformance suite every provider runs has a behaviour for exactly this, so a provider written outside this repository is held to it too.
When the scan finds something
Section titled “When the scan finds something”AF-MSK-002 Verification found data matching card number in orders.notes. Next: Add a masking rule for orders.notes and refresh the golden. The value itself is never printed.The value is never printed, and it is never written to a log, an artifact, or a CI annotation. A finding that quoted the data would publish it in the output of the job that caught it.
Add a rule and refresh:
rules: - table: orders column: notes transform: free_text why: "customers paste anything into this field"If the column genuinely holds no personal data and the detector is wrong, say so explicitly rather than deleting the check:
- table: orders column: notes transform: preserve why: "internal fulfilment codes, never free text from a customer"preserve is the exemption, and why is what makes it reviewable. An
exemption with no sentence beside it is a decision nobody can check later, and
af mask plan prints the sentence next to the column so it is read.
The attestation
Section titled “The attestation”A signed statement: which version, which rules, which detectors ran, how many rows and columns were scanned, which columns the scanner could not read, which columns masking copied unchanged with no rule, and what was found. It is stored with the golden so anyone holding an environment can read what was checked without asking the engine.
With the Neon provider it lives in the branch itself:
SELECT version, rules_hash, created_at, attestation FROM _antifailure.golden;It is signed so that an altered copy can be told from the original.
af fidelity reads the stored attestation back in a process that did not sign
it and checks the signature before repeating what it says. What that proves is
that the document was not changed after it was signed. It does not prove who
signed it, because the verifying key is generated for each signature and
travels inside the document, so a machine that trusts an attestation is
trusting whoever was able to write it.