docs(plans): plan the per-format encrypted credential columns

What

The plan for #417 (closed): move the three remote-repository tables from their interim tmp_plaintext_* columns to the encrypted_* shape S04-A's per-table instantiation specifies, put reads and writes behind crypto.RowEncryptor, and register each table with the rotation sweep. The spec this plan implements merged in !2274 (merged); this MR targets main directly and carries no spec text.

Settled: the migration discards the plaintext values

#417 (closed) says "backfill from the plaintext values". The plan discards them instead, and records the correction in Research Findings rather than applying it silently. The reasoning:

  • No backfill mechanism exists, and none can be a SQL statement. Encryption needs the Go crypto stack and the root key, so a plaintext-to-ciphertext conversion cannot run inside a migration. ADR-006 rejects the monolith's post-deployment migration model; goose runs at pod startup. The tree's backfill migrations derive their values in SQL, which a credential column cannot: no migration backfills a credential column, and none can.
  • Nothing is lost. Measured 2026-09-03, there is no traffic on .com and test traffic only on staging, so the three tables hold no production credential. Operators on staging re-enter what they configured this week.
  • The alternatives each add a step per format and a dual-column window: a River one-shot job builds a job tier the repo has never had; lazy migrate-on-write can never prove the plaintext columns empty, so the drop needs a separate sweep; an operator CLI backfill gates the drop on a manual step per environment. None of them buys anything against zero rows.
  • The discard branch owes operators a report. S13 records that a discarded row is indistinguishable from a repository deliberately configured against an anonymous upstream, and asks the branch that discards to name the affected rows at minimum. Steps 4, 7 and 10 carry that as a boot-time warn log naming each remote whose plaintext columns are still populated, by namespace id and repository id and never by value; the drop steps remove it. S13's stronger option, fencing resolution until credentials are re-entered, is not taken: with no production rows it would guard nothing, and it would add a read-path branch to three formats for a window that closes at Step 5.

Open Questions is therefore None., as docs/plans/TEMPLATE.md requires before implementation begins.

Settled: row counts are recorded once, not measured per migration

S04-A asks the FK migration and the validating migration to state the row count they rely on, because the greenfield justification the nearest precedent uses ("the table is empty in dev with no production deployment") is false now that the tables are deployed. The plan records the count once, in Dependencies, with its date: no traffic on .com and test traffic only on staging as of 2026-09-03, so no production rows. Steps 2, 6 and 9 cite that line in their migration headers instead of measuring again, and the ADR-004 cap of 1,000 repositories per namespace per format is why the number stays small afterwards. One statement per table; no per-partition decomposition of the FK or the index.

🔄 What the review of !2274 (merged) changed in this plan

The spec merged after rounds 4 and 5 of its review moved three things the plan had been written against:

Spec change Plan change
RowRef.Table is a typed crypto.TableName; the three values live beside the datastore's table bindings, from the generated table.<X>.TableName() Step 1 declares the type and no values; Steps 3, 7 and 10 each declare one
EncryptRow and DecryptRow reject a zero RowRef, enforced by cryptotest.RowEncryptorConformance Step 1 creates the conformance suite and runs it against DefaultRowEncryptor
The CHECK's VALIDATE CONSTRAINT lands in a separate migration Steps 2, 6 and 9 ship two migration files each
S13's fail-closed table names the health-probe and bearer-discovery arms Steps 4, 7 and 10 list remote_health.go (Maven, npm) and remote_auth.go (OCI)

Shape

Eleven steps: two shared, then three per format.

  1. crypto: bind the table and row identity into the column AAD 2–5. Maven: add columns → shared CredentialTable helper → behind RowEncryptor → drop plaintext 6–8. Container: add → table + behind RowEncryptor → drop 9–11. npm: add → table + behind RowEncryptor → drop

Why three steps per format, not two. Dropping a column takes two releases per docs/dev/database-migrations.md, so the drop cannot ride the code change that stops reading it: a pod on the previous image would still select it.

Why Maven first. Container repeats its basic-auth pair shape, and Maven has the smallest consumer surface: no virtual-resolution projection and no auth-discovery columns. npm goes last because it differs twice, with a single-attribute unit and npm_virtual_resolution.go, whose projection deliberately excludes the token column and carries a comment asserting it.

One helper, not three implementations. S04-B's credential-table seam expects one query pair compile-time bound to each table's jet types. The plan names the code smell it accepts: the helper takes generated jet column values across an interface boundary. Three hand-written query pairs would triple the surface where a missing LIMIT or a dropped optimistic guard hides, and those are the two defects the sweep's tripwires cannot catch per table.

Research Findings records the contradictions rather than fixing them quietly

  • #417 (closed) and five merged spec lines say backfill; the plan discards (above). The spec amendment is a merge gate on Step 4.
  • S04-A asks each migration for a measured count; the plan records it once (above).
  • #417 (closed)'s S15 line citations are stale (1079 and 1136-1137, not 1077/1134).
  • Its stale-#68 inventory undercounts by one: S30-maven-virtual.md:725.
  • The posture flip had five precedent borrows #417 (closed) does not name; all five are fixed in the specs as merged.

Gates

Two amendments merge before the steps they gate, both recorded in the plan's ## Dependencies:

  • Handbook ADR-007, before Step 2. The ADR publishes the three tables' column lists and declares the encrypted_* columns, but none of the key-wrap machinery, so the three new columns per table, the composite FK and the index diverge from the published schema.
  • The specs that prescribe a backfill, before Step 4. Five merged lines say it: S16-container-remote.md:56 and :1258, S04-b-encryption-key-rotation-tooling.md:445, and S04-c-column-level-encryption-wiring.md:128 and :180. docs/dev/column-encryption.md:7 says the same and is fixed in Step 1, which modifies that file already.

Not in scope

The re-encrypt CLI is already merged. #513 must clear before Step 4 merges: Step 4 is where the credential paths fail closed without the encryption block, and merging is not separable from deploying here, since a merge to main cuts the tag and Flux reconciles the image onto the staging pods. Rollback is clean while the plaintext columns still exist; Step 5 is where the drop makes it irreversible.

Related to #417 (closed)

Edited by David Fernandez

Merge request reports

Loading
Loading