Issue #144. The machine-readable
companion is schemas/privacy-inventory.yaml;
this page is the human-facing version. The architecture is
ADR-0020; the unresolved policy
decisions (legal basis, roles, retention periods) are
RFC-0002's D1–D13 and
open-decisions.md.
An inventory of every personal-data processing surface vpay owns: every database column across the authoritative schema, and every non-database surface (logs, metrics, traces, events, webhooks, the payer's browser, the rails, the merchant API, the observability listener, backups).
It is not a legal document. It classifies what the product stores,
derives, logs, emits and sends, so that the engineering controls (erasure,
retention, minimisation, export, incident evidence) can be built on a shared,
code-grounded map. Controller/processor roles, lawful bases, notices, retention
periods and disclosure approval are human decisions and are recorded here as
unresolved rather than guessed — RFC-0002 keeps each one open with a named
owner.
cargo xtask verify-privacy-inventory (a gate in just verify) reads
schemas/privacy-inventory.yaml and derives the authoritative database column
set by parsing backends/migrations (the source of truth — schemas/vpay.cstack
deliberately models less than the whole database). It fails in both
directions, exactly like verify-status:
- a migrated column with no element in the inventory → fail;
- an inventory element copy naming no live column → fail.
So a new privacy-relevant column cannot land silently, and a stale inventory row cannot survive the column it named. The gate also validates every element's six fields and every registered non-database surface.
(The parser models CREATE TABLE, ALTER TABLE … ADD/DROP/RENAME COLUMN and
DROP TABLE. DROP TABLE was added on review, 2026-09-17, and the omission
had cost exactly what this page exists to prevent: migration 0009 drops
merchant_api_keys outright, so all eight of that table's columns stayed in
the derived set, both directions of the gate agreed about a table no database
has, and this page published merchant_api_keys.key_digest/key_prefix as
stored merchant credentials in the oauth_client_secret row below. The eight
rows and that claim are gone; the column count moved 303 → 295.
a_dropped_table_leaves_no_columns_behind and
a_stale_row_naming_a_dropped_tables_column_fails_direction_b in .xtask pin
both halves.)
(A second review pass, 2026-09-18, found the same shape three more times,
and this time closed the class rather than the instances. The parser matched
its keywords as one literal space: CREATE TABLE with two spaces, a tab, or a
line break after CREATE derived nothing at all — the whole table, every
column of it, invisible to both directions. ALTER TABLE … ADD COLUMN email
lost email; DROP TABLE t left t standing, re-opening the 0009 hole
through a second space. Nothing here reformats SQL; the only thing keeping the
gate honest was that all 48 migrations happen to be typed with one space. The
match also had no word boundary and no string-literal awareness — these
migrations' COMMENT ON bodies are essays that discuss migrations, so one
containing the words DROP TABLE customers would have removed the real
customers table from the derived set. kw_end/words_at in .xtask replace
the whole approach, and a_keyword_pair_survives_any_whitespace_between_its_words,
a_keyword_inside_an_identifier_is_not_a_keyword and
a_ddl_keyword_inside_a_string_literal_is_data pin all three.)
(And the forms the parser does not model are now refused rather than
skipped. This paragraph used to end by naming ALTER TABLE … RENAME TO,
CREATE TABLE … (LIKE …) and PARTITION OF as unmodelled, and said the last
two "would produce a table with no columns, which does not [fail loudly]".
That was the honest thing to write and the wrong thing to leave: a table with
no columns is one neither direction of the gate can see, so the gate stays
green over an unclassified table — the 0009 failure again, in a form nobody
had written yet. create_table_parts now returns a three-armed CreateTable,
and AS SELECT, PARTITION OF, INHERITS, a (LIKE …) body and
ALTER TABLE … RENAME TO each fail the gate by name, telling the author to
model the form before landing the migration. None of the five appears in
backends/migrations today, which is now a fact the gate keeps rather than one
a reviewer has to re-establish.
a_create_table_the_parser_cannot_read_is_refused_not_skipped and
a_table_rename_is_refused_rather_than_silently_ignored pin it.)
(Schema qualification is dropped, not kept, and this page publishes the
bare names because of it. 0006 and 0013 create five tables as
authkestra.oauth_clients, …oauth_codes, …oauth_device_codes,
…oauth_dpop_jti and …oauth_refresh_tokens; the derived set — and therefore
the inventory, and therefore the tables named below — files them as
oauth_clients, oauth_codes and so on. Two tables of the same bare name in
different schemas would merge into one entry, and a column of either would then
satisfy a classification written for the other. No such pair exists: 31 created
tables, 31 distinct bare names. Said here rather than left in
normalise_sql_name's doc comment, because this page is where someone looks to
find out where a value is stored.)
Each entry in the inventory is a data element: a stable identifier shared by
every copy of one piece of data (ADR-0020 §1). Each element carries the eight
fields below and a copies list of the database columns (and, once
the non-DB registry is complete, the non-database surfaces) that hold it.
(This said "the six-field classification" and then listed eight until
2026-09-17. Six is what the gate checks is non-empty — subject, purpose,
tenant_boundary, retention, owner, control; necessary is a boolean
and recipients a list, so "present" is all either can be.)
(The subject row below listed four values until 2026-09-18, folding
"system/technical" into none. The gate enforces five —
PRIVACY_SUBJECT_VOCAB in .xtask/src/main.rs — and system is not a synonym
for none here: one element uses it, oauth_signing_key, and the honesty
note on that element below already treated system as its own value while
this table denied it existed. A field table that disagrees with the
closed vocabulary its own gate enforces is the kind of thing this page exists
not to be.)
| Field | Meaning |
|---|---|
subject |
data-subject category: payer, staff, merchant, none, or system |
purpose |
the product purpose for which the value is processed |
necessary |
whether the value is required for that purpose to function |
tenant_boundary |
merchant or system — which boundary the value is scoped to |
recipients |
third parties that receive it (e.g. mtn_momo, orange_money, merchant-endpoint) |
retention |
the retention class (not the period — the period is an RFC-0002 decision) |
owner |
the accountable retention owner (maintainer, operator, merchant) |
control |
the erasure/privacy control at DELETE/retention time: redact, none, forbid |
control is the bridge to the controls already built: redact is what the
customer erasure and retention sweep do (write the [redacted] marker, or
NULL where no integer is not a place); forbid is what the Debug-redaction
rule and RefundTarget's redacting Debug implement — a value that must never
reach a log or export.
Added on review, 2026-09-17; the coverage half was rewritten 2026-09-18.
Read control as the control this element should carry, not as a promise
that one runs today. The customer erasure and the retention sweep are the only
redact implementations in this repository, and between them they write
exactly ten statements (vpay_db::customers,
backends/crates/vpay-db/src/customers.rs:1294 and :1445–:1648):
customers' own identifier columns, events.data,
charges.payer_ref/payer_ref_masked/failure_raw, refunds.failure_raw,
idempotency_keys.response_body, webhook_deliveries.payload_sha256, and —
added 2026-09-18 — payment_intents.last_payment_error_code/
last_payment_error_message (NULLed together, forced by lpe_paired) and
— added 2026-09-23 with migration 0049, five more statements in
redact_out_of_band_references / redact_stored_invoice_responses_in_tx —
manual_payments.reference and its copies in invoice.* event bodies,
their deliveries' digests and excerpts, and stored invoice responses (see
payment_reference below), and
webhook_deliveries.response_excerpt (the [redacted] marker when non-null).
(Added 2026-09-23, ADR-0027:
no statement was added, but three reach further. The erasure's charges
statement (payer_ref, payer_ref_masked, failure_raw), its refunds
statement (failure_raw) and its payment_intents statement
(last_payment_error_*) used to find a payment only through
payment_intents.customer_id. They now find it through
vpay_db::customers::PAYERS_INTENTS. That constant also covers a
customer-less intent that one of the customer's checkout sessions names,
which is the shape a session created with customer= on such an intent left
before #253. It never covers an intent that names another
customer. No column and no classification moved, so
schemas/privacy-inventory.yaml is unchanged. What moved is the evidence
that the payer_msisdn and rail_failure_text controls run on those rows:
an_erasure_reaches_a_payment_whose_only_link_to_the_payer_is_a_checkout_session,
an_erasure_through_a_session_never_reaches_an_intent_that_names_another_customer,
a_session_create_and_an_erasure_of_its_customer_serialise_in_either_order
(a create can no longer attach an erased customer to a payment),
an_erasure_and_a_session_create_naming_another_customer_leave_that_customers_intent_alone,
and the whole-database scan, which now seeds that shape
(backends/tests/integration/tests/customers.rs; see
../status/verification/2026-09-23-erasure-through-checkout-sessions.md).
The line references in the paragraph above are from 2026-09-18 and are now
out of date. Search for the function names instead.)
Of the five columns that used to be reached by none of those statements, the erasure now covers three. Two remain, each a documented maintainer decision that is not taken:
| Column | Element | What actually protects it today |
|---|---|---|
payment_intents.last_payment_error_code |
rail_failure_text |
the erasure NULLs it, with _message, forced by the lpe_paired CHECK — 2026-09-18 |
payment_intents.last_payment_error_message |
rail_failure_text |
the erasure NULLs it, with _code, forced by the lpe_paired CHECK — 2026-09-18 |
webhook_deliveries.response_excerpt |
rail_failure_text |
the erasure writes the [redacted] marker when non-null — 2026-09-18 |
provider_requests.error_kind |
rail_failure_text |
a 128-character CHECK, and nothing else — a maintainer decision not to redact (below) |
staff_members.email |
staff_email |
the Debug impl (vpay_db::staff) — no staff erasure; staff erasure is a separate concern (#145) |
The heading that named five columns "nothing yet redacts" is itself the record
of the change: as of 2026-09-18 only two of the five are reached by no
erasure, and neither is an oversight. provider_requests.error_kind holds only
vpay's own operator labels (timeout/TLS/not_implemented), never rail prose —
the inventory classifies it rail_failure_text, but reclassifying it is a
maintainer decision and one that has not been taken. staff_members.email
is a staff subject, not a payer: the customer erasure structurally cannot reach
it, and a staff erasure is a separate concern, issue #145. Both stay listed here
so the decision is named rather than rediscovered.
This is an unmet criterion, not a gap in the file: the gate reads control
for its closed vocabulary and cannot know which statements exist, and no gate
here claims otherwise. The criterion is now met for three of the five
columns — the customer erasure covers them — and stays open only for
error_kind and staff_members.email, each pending the maintainer decision
above. It keeps #144 open alongside the six non-enumerable surfaces below, for
those two columns and for the two decisions. The two rail_failure_text
columns that were the only ones redacted — charges.failure_raw and
refunds.failure_raw — are redacted because they are "unbounded text a rail
wrote about this payer and may quote their number back" (customers.rs:1176);
the same leak argument holds for response_excerpt, which is an un-parsed copy
of what a merchant's webhook endpoint said and can echo the payload back.
last_payment_error_message is the exception and is stated honestly: every
writer stores the vpay-authored public_message() (the rail's prose lives in
charges.failure_raw), so NULLing it is defense-in-depth — redacted because the
inventory classifies the column rail_failure_text, not because it holds rail
prose today.
(Two corrections here, 2026-09-18, both found by reading the migrations rather
than the schema. The error_kind row said "and a closed operator-label
vocabulary"; there is no such vocabulary — backends/migrations/0016_create-provider-requests.sql:32-36
says the column is "Free text on purpose", and the only constraint on it is the
128-character CHECK. And the sentence above said the rail-quotes-your-number
argument "is true of the four above it"; it is not true of error_kind, which
the same migration says is set "when the attempt failed without an HTTP
status: a timeout, a TLS failure" — vpay's own connectivity label, written
precisely when the rail said nothing to quote. That makes error_kind's place
in rail_failure_text itself doubtful; see § "Classifications the review
could not settle".)
(And, 2026-09-18, the erasure now covers three of the five columns this
section used to list as reached by nothing. redact_stored_copies
(customers.rs:1445) NULLs payment_intents.last_payment_error_code and
last_payment_error_message together — lpe_paired forces the pair both-NULL
or both-set, and the code column's closed-vocabulary CHECK refuses a text
marker, so the redaction is an absence exactly as it is for the coordinate —
and replaces a non-null webhook_deliveries.response_excerpt with the marker,
for every delivery of the payer's customer.* events, the terminal ones
included. The two that remain — error_kind (vpay's own operator label, never
rail prose) and staff_members.email (a staff subject, structurally out of
reach of a customer erasure) — are maintainer decisions that are not taken,
named in the table above.)
Not an exhaustive table here — the authoritative copy is the YAML. The elements
whose subject is a person are:
| Element | Copies (DB) | Control |
|---|---|---|
payer_msisdn |
customers.phone, charges.payer_ref, charges.payer_ref_masked |
redact |
customer_name |
customers.name |
redact |
customer_email |
customers.email |
redact |
address_formal |
customers.address_line1…address_country |
redact |
address_gps |
customers.address_latitude_microdeg, customers.address_longitude_microdeg |
redact |
rail_failure_text |
charges.failure_raw, refunds.failure_raw, webhook_deliveries.response_excerpt, payment_intents.last_payment_error*, provider_requests.error_kind |
redact |
staff_email |
staff_members.email |
redact |
staff_display_name |
staff_members.display_name |
none |
staff_credential_secret |
credentials.material/subject/issuer (migration 0044 moved the staff credential columns here from staff_members) |
forbid |
staff_oauth_token |
staff_sessions.access_token |
forbid |
session_oauth_code |
oauth_codes, oauth_refresh_tokens, oauth_device_codes, oauth_authorization_codes, oauth_client_assertion_jtis, oauth_dpop_jti |
forbid |
webhook_url |
webhook_deliveries.url, checkout_sessions.success_url/cancel_url/return_url, oauth_*redirect_uri, charges.redirect_url/return_url |
forbid |
oauth_signing_key |
oauth_signing_keys.kid/public_jwk |
forbid |
oauth_client_secret |
oauth_clients.client_secret_hash/jwks, checkout_sessions.publishable_key/client_secret_suffix/return_token, payment_intents.client_secret_suffix |
forbid |
merchant_metadata |
customers.metadata, payment_intents.metadata/description, refunds.metadata, invoices.metadata |
none |
stored_api_body |
events.data, idempotency_keys.response_body |
redact |
merchant_note |
refunds.reason, invoices.description, invoice_items.description |
none |
payment_reference |
manual_payments.reference (migration 0049, 2026-09-23) |
redact |
payment_reference is a merchant's free text and is still subject: payer,
and that is the difference from merchant_note (added 2026-09-23, RFC-0004
§ 6). out_of_band[reference] exists to say how the payer paid — a cheque
number beside the drawer's name, a bank transfer's reference, which routinely
carries the payer's name — so it is data about the payer that a merchant typed,
where invoices.description is the merchant's note about their own bill. It is
control: redact and the control runs: the customer erasure
(vpay_db::customers::redact_out_of_band_references) writes the marker over
the column and over every copy — the invoice.paid body in events.data, the
live deliveries' payload_sha256 (cleared, for the same re-render reason as
the customer bodies) and response_excerpt, and the stored
POST /v1/invoices/{id}/pay response in idempotency_keys.response_body, the
last also on the far side of the issue-#111 race through
ResponseSubject::OutOfBandInvoice. And after an erasure the API refuses a
new reference on that customer's invoices (400 naming
out_of_band[reference]) rather than re-attach payer detail to a payer vpay
has erased; a payment without one still records.
erasing_the_customer_redacts_the_out_of_band_reference_everywhere (every
copy, delivery digests and excerpts included),
an_erasure_racing_an_out_of_band_payment_neither_deadlocks_nor_leaves_the_reference,
a_touch_of_an_invoice_paid_out_of_band_replays_redacted_after_an_erasure
and a_stored_invoice_response_written_after_an_erasure_is_redacted_as_it_lands
(backends/tests/integration/tests/invoices.rs), and the whole-database scan
an_erasure_leaves_no_payer_identifier_in_any_column_of_any_table
(customers.rs), which now seeds an out-of-band reference naming the payer,
are the evidence, all green against a real Postgres on 2026-09-23; see
../status/verification/2026-09-23-manual-payments.md.
Everything else is a sys_* element (subject: none) — identifiers, timestamps,
statuses, amounts, tenancy references, configuration, worker state — classified
for completeness so that a future column is caught in both directions, not
because they hold personal data.
Two honesty notes on that sentence, both added on review, 2026-09-17:
oauth_signing_keyis in the table above and itssubjectissystem, not a person. It is listed there because itscontrolisforbidand a reader looking for the protected columns should find it, not because a signing key identifies anyone. The 17 elements the gate counts as personal-data are the ones whosesubjectispayer,stafformerchant(16 untilpayment_referencejoined them on 2026-09-23).jobs.last_errorissys_job, and that classification is the one this page is least sure of. It storesvpay_core::error::display_with_chainof the worker's own error, bounded to 2 000 characters (vpay_worker::run_loop,backends/crates/vpay-worker/src/run_loop.rs:352), andProviderError::Rejected { code, message }renders the rail's own message into it — the same class of text ascharges.failure_raw, which this inventory callsrail_failure_text,subject: payer,control: redact. Calling itrail_failure_textwould be no better while it stays here, because that would assert aredactcontrol no statement performs onjobs(§ above). It is left assys_joband named here rather than moved quietly, because which of the two it is is a review question, not one a parser can answer (ADR-0020 §1: "No gate can determine … whether a stated purpose is necessary").
merchant_metadata, merchant_note and staff_display_name carry control: none deliberately, matching what the erasure already does: metadata is the
merchant's own key/value data and is deliberately not destroyed on erasure
(customers.md); a refund reason is
the merchant's free text, the same kind of thing metadata is.
events.data and idempotency_keys.response_body are not merchant_metadata.
They are their own element, stored_api_body (subject: payer, control: redact): each stores a complete rendered API object, and when that object is a
customer body it holds payer personal data — which is exactly why the erasure
rewrites these copies in the same transaction that erases the customer
(customers.md). The honest reading of
control: redact here is "the erasure rewrites the payload this container holds",
which is what the erasure statement in vpay_db::customers does.
schemas/privacy-inventory.yaml registers the disclosure surfaces ADR-0020 §1
names. Each is marked enumerable — whether the surface's contents are
exhaustively pinned by something outside this file, so that a source set
independent of the inventory could be derived from it.
The gate derives none of them. (Corrected 2026-09-17; this sentence read
"whether the gate can currently derive its contents", which was true of no
row in the table.) verify-privacy-inventory checks that surface ids are
unique, that none collides with an element name, and that surface and
description are present — and then counts the flag. An enumerable: true
row is a claim that the surface is closed and knowable (OTLP traces do not
exist; the event store is one already-classified column; browser storage is one
file; the observability listener is two routes), not evidence that anything
re-checks it. Nothing fails if OTLP tracing is added tomorrow.
| Surface | Enumerable | Status |
|---|---|---|
logs |
no | no static scanner over tracing! sites yet; the Debug-redaction rule is the only guard |
metrics |
no | metric-label derivation is not centrally registered |
traces |
yes | OTLP traces deliberately absent (docs/status.md: deferred) |
event_store |
yes | events.data — redacted on customer erasure |
webhook_payload |
no | per-event projections (RFC-0002 D2) not yet implemented; payload == API object today |
browser_storage |
yes | checkout page local memory on the payer's own device |
provider_rail |
no | no static scan of adapter request builders; guarded by RefundTarget's redacting Debug |
merchant_api |
no | wire object projections are per-resource, not centrally registered |
observability |
yes | /livez and /metrics on --observability-bind |
backup |
no | PITR/WAL and full backups — external store; retention of restore copies is a deployment obligation (RFC-0002 D13) |
A surface with enumerable: false is an unmet criterion: the gate records it
and keeps #144 open on it rather than manufacturing a self-check (RFC-0002 PR 2).
This page is the record of which surfaces still lack an independent source set.
Added 2026-09-18. A second review read ten subject: payer|staff|merchant
columns and ten subject: none|system columns against the migration that
defines each one. It found no fabricated compliance claim, no invented lawful
basis and no retention period stated as a commitment. It did find six
classifications that the migrations' own comments contradict. None is
changed here: each needs an element split or a judgement that RFC-0002 D1
(legal basis, controller/processor, purpose necessity) has not yet made, and
quietly re-deciding a GDPR artifact in a review pass is the failure this
directory exists to prevent. They are unmet criteria of #144, listed with
the line that contradicts them so the decision can be made rather than
rediscovered.
| Classified as | The migration says | Where |
|---|---|---|
checkout_sessions.publishable_key — oauth_client_secret, subject: merchant, control: forbid |
"NOT a secret: it names a tenant and authorises nothing" | backends/migrations/0028_create-checkout-sessions.sql:284 |
checkout_sessions.client_secret_suffix, checkout_sessions.return_token, payment_intents.client_secret_suffix — oauth_client_secret, subject: merchant |
"Two payer credentials"; "the second half of this session's payer-facing client_secret" |
0028_create-checkout-sessions.sql:282, :286; 0026_payment-intent-client-secret.sql:64 |
authkestra.oauth_clients.jwks and oauth_signing_keys.public_jwk — control: forbid |
both exist to be published: "must never hold a private component"; "so /jwks.json can publish every currently-valid key" |
0013_add-authkestra-op-0-7-columns.sql:82, 0010_reshape-oauth-signing-keys.sql:47 |
payment_intents.last_payment_error_code — rail_failure_text, subject: payer |
a closed failure_code enum, exactly like charges.failure_code and refunds.failure_code, which this inventory classifies sys_status, subject: none |
0014_payment-intent-api-fields.sql:86 |
provider_requests.error_kind — rail_failure_text, subject: payer |
vpay's own operator label, set "when the attempt failed without an HTTP status: a timeout, a TLS failure" — written precisely when the rail said nothing to quote | 0016_create-provider-requests.sql:32 |
charges.provider_ref_extra — sys_provider_ref, subject: none, control: none |
an open rail-supplied BTreeMap<String, String> "captured from a previous submit", reached by no erasure statement — the same untrusted-rail-input class rail_failure_text exists for |
0004_create-charges.sql:42 |
Four of the six are one defect wearing four hats: an element carries one
classification and oauth_client_secret holds both genuine merchant secrets
and payer-facing session credentials. That is § "What ADR-0020 §1 asks for and
this file does not yet carry", below, with a name and a line number attached.
Added on review, 2026-09-17, because the alternative was for the gap to be
visible only by reading the ADR beside the schema. ADR-0020 §1 requires every
entry to identify "its source … and code or configuration reference", and
every additional copy to name "why it is necessary and which control removes or
protects it". This inventory carries necessity and control per element, and
a copy is {kind, table, column} with nothing of its own. So:
- there is no per-copy necessity or control, and no per-element code or configuration reference;
- an element whose copies are protected differently —
rail_failure_textis the live example, § "control: redactnames five columns nothing yet redacts" — cannot say so in the schema.
Both are unmet criteria of #144, not decisions. Adding either field is a schema change the gate would have to require in both directions, which is PR 2's successor work, not PR 1's.
Every element's retention names a class, never a period: the customer
class, the auth class, the operational class. The periods are RFC-0002 D9
(and the current hard-coded 365-day customer rule is in explicit conflict with
D9's "approved, configurable" requirement — see RFC-0002). Legal basis,
controller/processor role and purpose-necessity approval are RFC-0002 D1 and
are unresolved, not guessed.
2026-09-16, amended 2026-09-17 on review. The inventory exists and is
machine-checked in both directions against backends/migrations by cargo xtask verify-privacy-inventory, wired into just verify. 307 database columns
across 26 elements (17 personal-data), and 10 non-database surfaces, are
registered — the gate's own output on 2026-09-23, after migration 0049
added twelve columns (invoices.paid_out_of_band and the eleven of
manual_payments, the last its always-true paid_out_of_band, classified
sys_status) and the payment_reference element; it said 295 across 25 (16)
until then, and 306 for the few hours 0049 had eleven.
What is not done, and each of these keeps #144 open:
- the six
enumerable: falsesurfaces lack independent static scanners (recorded above, not faked), and the gate enumerates none of the other four either — it counts the flag; - three of the five columns that carried
control: redactand no redacting statement are now covered by the customer erasure (§ above); the remaining two —provider_requests.error_kindandstaff_members.email— still have no redacting statement and are open maintainer decisions (a reclassification, and a staff-erasure feature, issue #145), not oversights; - six classifications are contradicted by the migration that defines the column (§ "Classifications the review could not settle", 2026-09-18) and are left for a maintainer, not re-decided here;
- ADR-0020 §1's per-copy necessity/control and per-element source reference are absent from the schema (§ above);
- the
@vpay/api-clientand per-event webhook projections RFC-0002 D2 needs are unbuilt; - every RFC-0002 D1–D13 policy answer remains open.
This is the foundation stage of the GDPR epic, not the epic.
(This section read "Personal-data columns and the non-database surfaces are
registered" with three unmet items until 2026-09-17. It was not wrong, but it
was shorter than the truth: the review added DROP TABLE to the parser — which
removed eight columns of a table migration 0009 deleted, 303 → 295 — and
found the two claims now listed above it.)