Skip to content

Add read-only migration-readiness verifier (PR 13 slice) - #2056

Open
chobble-opencode-vm[bot] wants to merge 7 commits into
mainfrom
work/migration-readiness
Open

Add read-only migration-readiness verifier (PR 13 slice)#2056
chobble-opencode-vm[bot] wants to merge 7 commits into
mainfrom
work/migration-readiness

Conversation

@chobble-opencode-vm

@chobble-opencode-vm chobble-opencode-vm Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Current-system value

Operators can now run deno task migration-verify against the configured
database — a live one, or one freshly restored from an old backup into the
current application — and prove whether the legacy payment tables, attendee
PII, and merge references are safe to migrate, without writing anything
.
Before this, an operator had no way to check migration readiness ahead of the
forward copy.

The command exits 0 (ready) or 1 (blocked) and prints a bounded verdict:
source counts, the owner-key verdict, and each contradiction in plain language.
Exit 2 covers a usage or source-read failure.

Production / operator caller

deno task migration-verify [--owner <username>] [--page-size <n>] — wired as
a deno.json task. Entry: scripts/migration-verify.ts; orchestration:
scripts/migration-verify-lib.ts; DB + crypto wiring:
scripts/migration-verify-deps.ts. The pure rules live in
src/shared/migration-readiness/readiness.ts and the operator report renderer
in src/shared/migration-readiness/format.ts; both unit-tested directly.

What it does (PR 13 scope, one coherent read-only slice)

  • Losslessly inspects processed_payments (incl. failure_data),
    checkout_stages, sumup_checkouts (non-secret columns), attendee PII blobs,
    and attendee-merge references (legacy-merge:<sourceId> rows).
  • Keyset-paginates each table by its primary key at the operator's
    --page-size (default 500); groups one provider payment
    (buildPaymentGroups) for a coherent report. (payment_session_id is the
    primary key, so a keyset page can never split a single payment — the split
    risk is a PR 14 copy-cursor concern.)
  • Converts old timestamps to canonical …sssZ (ISO-8601 or old whole-
    epoch-millis), rejecting impossible dates (Feb 30) and epoch overflow.
    Required timestamps (processed_at, checkout_stages.created_at,
    sumup_checkouts.created_at) that are empty contradict; only
    provider_refunded_at is optional.
  • Surfaces bounded contradictions: orphan checkout stages, stages/processed
    payments pointing at deleted attendees, a stage and processed payment that
    disagree on attendee, terminal handled failures exempt (attendee_id NULL +
    failure_data set), unconvertible/empty-required timestamps, sumup checkouts
    without a recorded id, undecryptable PII / payment references (hybrid
    ciphertext that fails to decrypt or decrypts to an empty charge).
  • Enforces the owner-authenticated private-key controls: derives the site
    private key from an owner-level account's password (non-owners are
    refused) and decrypts (and parses, via the real PII schema) every attendee
    PII blob and every hybrid payment_reference (regular charges and merge-
    reference handoffs; legacy plaintext refs don't need the key). If no owner
    key is supplied (or derivation fails) and encrypted PII or hybrid payment
    references exist, the verdict blocks — it never silently skips charges.

    The owner password is read from non-tty stdin (or MIGRATION_VERIFY_PASSWORD)
    without echo; prompt() is the interactive fallback. PII plaintext never
    leaves the decrypt step.

Codex review — all 20 findings addressed (7 commits)

Round 1 (160565848): owner-key-unavailable blocks on merge-ref charges;
--page-size flows to the reader; removed false-positive merge-source-attendee
check; failure_data exempts terminal failures; non-tty password read;
checkout_stage_without_attendee check.

Round 2 (10ac3c4c0): non-hybrid/malformed PII fails (decrypt AND parse);
epoch-overflow catch + empty-required-timestamp contradictions; --owner
restricted to owner-level; all payment_references verified (not just merge
refs) → undecryptable_payment_reference; removed dead
payment_split_across_page (PK uniqueness); readAttendeeIds filters
kind='attendee' (excludes servicing rows).

Round 3 (e12744b9f): split formatReadinessReport into format.ts
(readiness.ts 486→421) and the format tests into format.test.ts
(readiness.test.ts 567→425) with shared fixtures.ts. The two "reject null
keys before advancing the cursor" comments need no code change — ORDER BY pk ASC sorts NULLs first, so the page cursor is never NULL and pk > NULL
truncation cannot occur.

Round 4 (32579fe70): block sessions where the stage and processed
attendees disagree (checkout_stage_attendee_mismatch); hasEncryptedPaymentReference
keys on the hybrid prefix so plaintext-only DBs don't block without --owner;
reject hybrid payment_reference that decrypts to "" (corrupt).

Coverage (c41d0818a): removed the dead empty-blob skip in verify
(never reached — readAttendeePii filters empty blobs); added CLI tests for
--page-size parsing and an integration test for a legacy plaintext charge.
All changed source now at 100% line + branch.

Shared helpers (dedup)

  • src/shared/db/database-config.ts — one DB-config validator used by both
    deno task restore and deno task migration-verify.
  • src/shared/crypto/owner-kek.tsderiveOwnerKek (v1/v2 KEK dispatch) +
    privateKeyFromDataKey, used by the verifier (login flow keeps its inline
    dispatch as a documented follow-up).

Good-citizen fix (recorded separately)

TODO.md (last touched by #2046) broke the deno fmt --check gate on main —
commit 54426664b applies the mechanical deno fmt wrapping (empty word-diff:
no content change). Unblocks the PR's checks CI job and main.

Source and total line counts

  • src/ insertions + deletions vs main: 630 (limit: 800).
  • Total diff: 2,794 (added + deleted; incl. tests, scripts, equivalent-
    mutants entry, and the TODO.md reformat).
  • keys.ts and auth.ts are unchanged vs main.
  • Commits: 7 (005fe6a slice, 5442666 TODO fmt, 1605658 Codex r1,
    10ac3c4 Codex r2, e12744b Codex r3 split, 32579fe Codex r4,
    c41d081 coverage).

Database and provider call budget

Full-Deno CLI (not an edge isolate), so not bound by Bunny's 50-subrequest
budget. Reads are keyset-paginated. Zero provider calls — only legacy DB
reads + owner-key decryption. One settings.loadKeys read for the wrapped
private key.

Tests and mutation

  • test/shared/migration-readiness/readiness.test.ts — pure rules.
  • test/shared/migration-readiness/format.test.ts — report rendering.
  • test/shared/migration-readiness/fixtures.ts — shared fixtures.
  • test/shared/db/database-config.test.ts — config validator.
  • test/shared/crypto/owner-kek.test.ts — KEK dispatch + key recovery.
  • test/scripts/migration-verify.test.ts — CLI orchestration (fakes).
  • test/integration/migration-verify.test.ts (16) — end-to-end against a
    setup-complete test DB (real owner-key derivation, keyset pagination, terminal
    failures, merge + regular + plaintext + empty-decrypted charge verification,
    non-owner refusal, servicing-row exclusion, --page-size flow).
  • Restore + login suites re-pass unchanged.

Coverage: all changed source at 100% line + branch.
Targeted mutation (--jobs 1 --timeout 60000):
readiness.ts 100% (101/101), format.ts 100% (37/37),
database-config.ts 100% (27/27), owner-kek.ts 100% (4/4).
keys.ts/auth.ts unchanged vs main (outside the diff, not mutated).
scripts/ files aren't mutation-gated (same convention as restore-lib.ts).

Known PLAN.md faults addressed

A read-only readiness check against the legacy reader tables — addresses PR 13's
inspect-losslessly / group-before-pagination / convert-timestamps / surface-
contradictions / owner-key-control requirements, proving readiness before PR
14 changes payment history
. It detects (does not fix) the conditions that
would trigger PR 14 copy faults.

Deferred PR 13 / follow-up work

  • Folding the login flow onto deriveOwnerKek (kept inline to avoid pulling
    keys.ts/auth.ts whole-file mutation into this slice).
  • Old-schema-backup verification before restore into the current app.
  • A fully masked interactive-TTY password reader (needs raw-mode handling, no
    deterministic in-process test) — the non-tty/env paths are the testable
    no-echo routes.
  • readiness.ts is 421 lines (just over the soft ~400 target); the format
    split removed the renderer, leaving cohesive diagnosis logic further
    splitting would fragment.

PR 3 paths untouched

No provider adapters, payment callback/classification/refund paths,
PR3_PLAN.md, or aggregate-runtime code modified. The verifier only reads the
legacy tables and decrypts PII with the owner key. (keys.ts and auth.ts
have zero net diff vs main.)

Operators can run `deno task migration-verify` against the configured
database (a live one, or one freshly restored from an old backup into the
current application) to prove the legacy payment tables, attendee PII, and
merge references are safe to migrate before a later release changes payment
history — without writing anything.

The pure rules in `src/shared/migration-readiness/readiness.ts` group one
provider payment before pagination, convert old timestamps, and surface bounded
contradictions across processed_payments, checkout_stages, sumup_checkouts,
attendee PII, and attendee-merge references. The owner-key control for
encrypted attendee PII is enforced: the verifier derives the site private key
from an owner-authenticated password and blocks (rather than skipping) the
encrypted charges when the key is unavailable.

Shares one database-config validator with the restore CLI (dedup) in
`src/shared/db/database-config.ts`, and one owner-KEK dispatch plus a
private-key-from-data-key helper in `src/shared/crypto/owner-kek.ts` (used by
the verifier; the login flow keeps its inline dispatch). Touches no provider
adapters, payment callback/classification/refund paths, PR3_PLAN.md, or
aggregate runtime.
@chobble-opencode-vm
chobble-opencode-vm Bot force-pushed the work/migration-readiness branch from d933802 to 005fe6a Compare August 9, 2026 14:54

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d933802133

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/shared/migration-readiness/readiness.ts Outdated
Comment thread scripts/migration-verify.ts Outdated
Comment thread src/shared/migration-readiness/readiness.ts Outdated
Comment thread scripts/migration-verify-deps.ts Outdated
Comment thread scripts/migration-verify.ts Outdated
Comment thread src/shared/migration-readiness/readiness.ts Outdated
Good-citizen fix: TODO.md (last touched by #2046) broke the `deno fmt --check`
gate on main — its prose exceeded 80 columns. Pure reformatting (verified by
empty word-diff), no content change. Unblocks the PR 13 verifier's CI checks
job, which runs `lint:ci`.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 54426664b6

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread scripts/migration-verify-deps.ts Outdated
Comment thread src/shared/migration-readiness/readiness.ts Outdated
Comment thread scripts/migration-verify-deps.ts
Comment thread scripts/migration-verify-lib.ts Outdated
Comment thread scripts/migration-verify-deps.ts
Comment thread scripts/migration-verify-lib.ts Outdated
Comment thread scripts/migration-verify-deps.ts
…ord echo

Six Codex P2 findings, all verified against the codebase and fixed with
regression tests:

1. Owner key unavailable now also blocks on encrypted merge-reference charges,
   not only attendee PII — no silent skip when no key is supplied (PLAN.md
   PR 13: do not silently skip charges).

2. `--page-size` now flows to the database reader (factory `createReader`),
   so the keyset page size that bounds each read matches what the operator
   asked for (previously the reader always used the default 500).

3. Removed the false-positive `merge_reference_without_source_attendee` and
   the redundant `merge_reference_without_target_attendee` checks:
   `applyAttendeeMerge` deletes the source attendee in the same batch, so the
   source id is historical and the row's attendee_id (the merge target) is
   already covered by the general `processed_payment_without_attendee` check.

4. Read `failure_data`; exempt terminal handled failures (attendee NULL +
   failure_data set) from the missing-attendee check — only unresolved stuck
   reservations (attendee NULL + failure_data '') block.

5. Owner password read from non-tty stdin without echo (or
   `MIGRATION_VERIFY_PASSWORD` env var); `prompt()` remains the interactive
   fallback with a documented echo caveat.

6. Added `checkout_stage_without_attendee`: a stage whose own attendee_id
   points at a deleted attendee is now flagged even when the session shares a
   processed payment.

readiness.ts mutation 100% (125/125, 1 provably-equivalent suppressed).

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1605658487

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread scripts/migration-verify-deps.ts
Comment thread scripts/migration-verify-deps.ts
Comment thread src/shared/migration-readiness/readiness.ts Outdated
Comment thread test/shared/migration-readiness/readiness.test.ts Outdated
…e refs, timestamps, servicing, dead-split removal

Seven more Codex P2 findings, all verified and fixed:

1. (deps) A non-empty pii_blob that is not hybrid ciphertext is no longer
   counted as verified — decryptPiiBlob is used (decrypt AND parse), so a
   corrupt-plaintext or malformed-JSON blob fails readiness.
2. (readiness) convertLegacyTimestamp now catches epoch-millis values outside
   Date's representable range (returns null instead of throwing). Required
   timestamps (processed_at, checkout_stages.created_at, sumup_checkouts.
   created_at) that are empty now contradict; only provider_refunded_at is
   optional.
3. (deps) --owner now requires an owner-level account: derive returns null for
   any non-owner (manager/agent/editor) carrying the site data key, so a
   non-owner cannot make the verifier decrypt PII.
4. (lib) Every processed_payments row with a non-empty payment_reference is
   verified (not only legacy-merge handoffs); a corrupt regular captured charge
   is reported as undecryptable_payment_reference.
5. (deps) PII blobs are parsed through the real PII schema after decrypt, so
   malformed-but-decryptable blobs (missing payment_id etc.) fail readiness.
6. (readiness) Removed the dead paymentsExceedingPage / payment_split_across_page
   check: payment_session_id is a primary key, so within one table a payment
   appears at most once and a keyset page can never split it (split detection
   is a PR 14 copy-cursor concern, not this read-only verifier).
7. (deps) readAttendeeIds filters kind = 'attendee', so a payment pointing at
   a servicing (van/crew) row blocks instead of reading as a valid attendee.

readiness.ts mutation 100% (127/127, no survivors, no suppressions needed).
Addresses two Codex round-3 P2 findings on file size:

- Move formatReadinessReport + CONTRADICTION_PHRASES out of readiness.ts into
  src/shared/migration-readiness/format.ts (readiness.ts: 486 -> 421 lines;
  the remaining content is the pure diagnosis rules).
- Move the formatReadinessReport test suite into
  test/shared/migration-readiness/format.test.ts, with shared fixtures
  (enc/stage/processed/goodInput/mergeRefNoOwnerInput) extracted into
  fixtures.ts so neither test file duplicates them (readiness.test.ts: 567 ->
  425; format.test.ts: 118).

readiness.ts mutation stays 100% (90/90); format.ts mutation 100% (37/37).
The two 'reject null keys before advancing the cursor' comments need no code
change (explained in review replies): every paged table uses ORDER BY pk ASC,
and SQLite sorts NULLs first, so the cursor (last row of each page) is never
NULL and pk > NULL truncation cannot occur; NULL-pk rows are read, not skipped.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e12744b9f7

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/shared/migration-readiness/readiness.ts
Comment thread src/shared/migration-readiness/readiness.ts Outdated
Comment thread scripts/migration-verify-deps.ts Outdated
…mpty-decrypted charge

Three Codex round-4 P2 findings, all verified and fixed:

1. (readiness) A session whose checkout_stages row and processed_payments row
   name different live attendees is now a contradiction
   (checkout_stage_attendee_mismatch) — previously passed because the stage
   and processed attendee checks were independent.
2. (readiness) hasEncryptedPaymentReference now keys on the hybrid ciphertext
   prefix, so a database with only legacy plaintext payment_reference rows no
   longer blocks when --owner is omitted (plaintext charges do not need the
   owner key).
3. (deps) paymentReferenceDecrypts rejects a hybrid payment_reference that
   decrypts to an empty string (an encrypted-but-empty charge is corrupt).

Integration tests: 'blocks on a hybrid payment_reference that decrypts to an
empty string' and unit: mismatch + plaintext-ref-doesn't-block. readiness.ts
mutation 100% (101/101).
…files)

- Remove the dead empty-blob skip in verify (readAttendeePii filters
  pii_blob != '', so empty blobs never reach it) — was an uncovered,
  impossible branch.
- Add CLI tests for --page-size parsing: a numeric value reaches the reader
  with that page size, and a non-numeric value exits 2 with usage. Extract
  expectUsageExit to dedup the two usage cases.
- Add an integration test for a legacy plaintext (non-hybrid) payment
  reference with the owner key (covers paymentReferenceDecrypts' non-hybrid
  branch).

All changed source now at 100% line + branch coverage (migration-verify-deps,
migration-verify-lib, readiness, format, database-config, owner-kek).

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c41d0818a1

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

),
readProcessedPayments: () =>
keysetRows<ProcessedPaymentRow>(
"SELECT payment_session_id, attendee_id, processed_at, payment_reference, provider_refunded_at, failure_data FROM processed_payments",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Verify stored ticket-token ciphertext

When a finalized processed_payments row has non-empty ticket_tokens that no longer decrypts, this reader never loads that column, so diagnoseReadiness can still print ready even though the current replay path decrypts processed_payments.ticket_tokens in alreadyProcessedResult and the forward migration has to preserve that ticket state. Include and verify non-empty ticket-token ciphertext before claiming the legacy payment row is safe.

Useful? React with 👍 / 👎.

Comment on lines +69 to +70
return Deno.stdin.isTerminal()
? prompt(message)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Mask the interactive owner password

Fresh evidence in the current code is that the terminal branch still calls prompt(message), and the adjacent comment states that this echoes; when an operator runs deno task migration-verify --owner ... from an interactive terminal without piping stdin or setting MIGRATION_VERIFY_PASSWORD, the password protecting attendee PII is still typed visibly. Use a non-echoing TTY reader for this branch instead of making the secure path opt-in.

Useful? React with 👍 / 👎.

Comment on lines +171 to +172
const dataKey = await unwrapKey(user.wrapped_data_key, kek);
return privateKeyFromDataKey(dataKey, settings.wrappedPrivateKey);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Return null when owner-key unwrap fails

When a restored database has a corrupt or mismatched wrapped_data_key or wrapped_private_key, these awaits reject instead of returning null, so assessOwnerKey never reaches its bounded “owner key not supplied” verdict and the operator gets an unhandled error for a source-readiness condition. Catch unwrap/import failures in derive and return null so encrypted PII and payment references block normally.

Useful? React with 👍 / 👎.

),
readSumupCheckouts: () =>
keysetRows<SumupCheckoutRow>(
"SELECT reference_index, sumup_id, created_at FROM sumup_checkouts",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Read the encrypted SumUp staging fields

When a sumup_checkouts row has an empty wrapped_key or metadata value, this SELECT discards both fields and the readiness rules only check sumup_id, so the report can say ready even though the current getSumupCheckout path requires those encrypted fields to recover the staged booking metadata. Include those columns in the input and block empty values before certifying the row as migratable.

Useful? React with 👍 / 👎.

* (attendee null + failure_data ''), a terminal failure is a normal stored
* outcome and must not block migration. */
const isTerminalFailure = (row: ProcessedPaymentRow): boolean =>
row.attendee_id === null && row.failure_data !== "";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Parse failure_data before accepting terminal rows

When a row has attendee_id NULL and any non-empty but corrupt failure_data, this predicate exempts it as a handled terminal failure, so readiness can pass without proving the encrypted failure JSON still decrypts and carries the refund facts the migration must preserve. Validate that blob at the boundary or report a contradiction before treating the row as safe.

Useful? React with 👍 / 👎.

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.

1 participant