Skip to content

Add the tables one payment record lives in - #2007

Merged
stefan-burke merged 4 commits into
mainfrom
claude/payment-record-tables
Jul 30, 2026
Merged

Add the tables one payment record lives in#2007
stefan-burke merged 4 commits into
mainfrom
claude/payment-record-tables

Conversation

@stefan-burke

@stefan-burke stefan-burke commented Jul 30, 2026

Copy link
Copy Markdown
Member

The first slice peeled off #1973, which had grown to 8,300 lines and become unreviewable. This is about 1,400 lines and can be read on its own.

It adds the six tables a payment record lives in and the migration that creates them. Nothing writes to them yet — the repositories, the runtime, and the copy that brings older payments across stay in #1973 and follow separately.

Why six tables

A payment is not one fact. It is a checkout we opened, money that may or may not have arrived, work that has to happen once it does, messages that have to go out, and — when something does not add up — a problem for the owner to settle. Those things change at different times, are written by different code, and are looked up in different ways. Keeping them in one row would mean every small update rewrites the lot, and would make "has this already been done?" impossible to ask cheaply.

So: one row for the payment, one row per thing taken, one row per job, one row per message, one row per problem, one row per decision.

Table What it holds
payment_sessions The payment itself: who was asked for the money, how much, what for, where it got to, and how it turned out.
payment_charges The money actually taken. Separate because money and intent are not the same: a payment can exist with no money, and money can arrive that no payment expected.
payment_completion_effects The jobs done once and only once after the money arrived. The row is the record that it happened, so a repeated callback cannot make the same booking twice.
payment_completion_deliveries The messages to send. Separate from the jobs because a message can fail and be tried again, while a job must never run twice.
payment_cases Anything that needs sorting out: a provider that cannot be reached, money that does not add up, a payment nobody can match.
payment_case_decisions What the owner chose, and how it went. Separate because a decision is taken once, then carried out, and carrying it out can fail and be retried.

What the tables say, and what they leave to the code

The tables say what each column is — its type, whether it may be missing, what it starts as — and nothing about how a payment may behave.

Rules about behaviour live in the code instead. A rule in a table is hard to maintain: it says nothing useful when it fails, it cannot be tried without a database, and changing one means rebuilding a table already full of real money. The same rule in TypeScript names itself when it breaks, can be unit tested in isolation, and can be changed freely. So a payment's behaviour is checked where it can be understood and where a failure explains itself.

One rule stays on the tables, because it is the one thing the code cannot check itself: that a column holding the buyer's details really contains encrypted text. A type says string while the value is bytes — TypeScript cannot see the difference, and the table can. That rule sits at the last point before the data comes to rest.

That check is stricter than it looks, and for a reason found by probing rather than reading: SQLite leaves a blob alone in a TEXT column, but GLOB turns one into text just long enough to compare it. So bytes spelling a real envelope passed, and were stored as bytes — a row written, looking correct, that nothing binding the same value as a string could ever find again. The rule now asks for real text first, and the tests bind real bytes rather than writing them out.

What this does not do

Nothing writes to these tables. They are wired into the schema, so every part of this slice has a real user and nothing here is exempted from the unused-code check.

The migration is new, so no site has these tables yet and there is nothing to re-shape.

Checks

Run against this branch on its own, on top of current main, not inherited from #1973:

  • deno task precommit passes, including the check that every export has a real user
  • every table file is at a full mutation kill rate — cases 39/39, charges 34/34, columns 24/24, decisions 25/25, sessions 50/50, completion-effects 8/8, completion-deliveries 13/13
  • no mutants are recorded as known-equivalent for these files

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added durable payment record storage for sessions, charges, cases, decisions, completion effects, and completion deliveries.
    • Added database indexes to support payment tracking, reconciliation, retries, and duplicate prevention.
    • Added safeguards for sensitive payment information and encrypted data handling.
  • Bug Fixes

    • Improved consistency and idempotency when recording payment activity and completion events.
  • Tests

    • Added comprehensive coverage for payment schemas, constraints, uniqueness, ordering, and migration integrity.

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 42 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: f242393a-81fb-480a-a739-9ddbef728eeb

📥 Commits

Reviewing files that changed from the base of the PR and between a45c668 and 944e99e.

📒 Files selected for processing (2)
  • test/shared/db/migrations/registry.test.ts
  • test/shared/db/migrations/schema/payments/decisions.test.ts
📝 Walkthrough

Walkthrough

Adds six durable payment tables with shared SQLite constraints, indexes, encrypted-field rules, migration registration, ordered schema assembly, and tests covering metadata and database behavior.

Changes

Payment records schema

Layer / File(s) Summary
Payment schema contracts
src/shared/db/migrations/schema/payments/*
Reusable column helpers define encrypted, nullable, defaulted, timestamp, and row-level constraints; six payment tables define their columns and indexes.
Schema assembly and migration wiring
src/shared/db/migrations/schema/index.ts, src/shared/db/migrations/schema/payments/index.ts, src/shared/db/migrations/registry.ts, src/shared/db/migrations/2026-07-26_payment_records.ts, src/shared/db/migrations/schema/version.ts
Payment tables are aggregated into SCHEMA, registered as 2026-07-26_payment_records, and reflected in schema version metadata.
Schema and database validation
test/shared/db/migrations/schema/payments/*, test/shared/db/migrations/schema/index.test.ts
Tests verify generated definitions, schema hashes, index names, encryption checks, defaults, uniqueness, and payment-table behavior.
Migration metadata validation
test/shared/db/migrations/2026-07-26_payment_records.test.ts, test/shared/db/migrations/registry.test.ts, test/shared/db/migrations/schema/version/guard.test.ts
Tests verify migration requirements, ordering, registered identifiers, schema hashes, and the latest update label.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant MigrationRegistry
  participant PaymentMigration
  participant SchemaAssembly
  participant SQLite
  MigrationRegistry->>PaymentMigration: Load payment records migration
  PaymentMigration->>SchemaAssembly: Declare payment tables and indexes
  SchemaAssembly->>SQLite: Create payment schema objects
  SQLite-->>SchemaAssembly: Return schema metadata
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title matches the main change: adding the new payment-related tables.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/payment-record-tables
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch claude/payment-record-tables

Comment @coderabbitai help to get the list of available commands.

@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: 67036adfde

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// Money can never be given back beyond what was taken. This holds whatever
// the refund is doing, so it belongs with the money rather than the runtime.
"refunded_amount IS NULL OR captured_amount IS NULL OR refunded_amount BETWEEN 0 AND captured_amount",
`provider IS NULL OR resource_kind IS NULL OR ${providerMatchesKind}`,

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 Require provider and resource kind together

When a charge supplies only one identifier, this clause short-circuits to true because either provider IS NULL or resource_kind IS NULL passes the whole check. That admits records that cannot satisfy RESOURCE_KIND_BY_PROVIDER, leaving reconciliation or refund code without a complete provider/resource pair; require both fields to be null or both present and matched.

AGENTS.md reference: AGENTS.md:L85-L86

Useful? React with 👍 / 👎.

/** What a decision may never be, whatever else is true of it. */
const aboutTheDecision = alsoAbout([
"(attempt_count = 0) = (last_attempt_at IS NULL)",
"next_retry_at IS NULL OR last_attempt_at IS NULL OR next_retry_at >= last_attempt_at",

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 Require an attempt before scheduling its retry

When next_retry_at is set while last_attempt_at is null, this OR accepts the row; the preceding invariant then requires attempt_count = 0, yet the retry index can expose it as due work. A retry must require a non-null last-attempt time and occur no earlier than that attempt, rather than treating the missing attempt as valid.

AGENTS.md reference: AGENTS.md:L85-L86

Useful? React with 👍 / 👎.

wholeNumberOrNull("evidence_redacted_at", "resolved_at"),
],
["revision", wholeNumber("revision", 1, 1)],
["resolved_at", aboutTheCase("INTEGER")],

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 Tie the resolved timestamp to the resolved state

When a case is saved as resolved without resolved_at, or with resolved_at while still retrying/needs_action, this definition accepts it because it only validates a timestamp when one happens to be present. That leaves the state and lifecycle timestamp contradictory; notably, a resolved case without the timestamp cannot later satisfy the evidence_redacted_at >= resolved_at rule. Enforce that resolved_at is present exactly when state = 'resolved'.

AGENTS.md reference: AGENTS.md:L85-L86

Useful? React with 👍 / 👎.

["provider", oneOfOrNull("provider", PROVIDERS)],
["resource_kind", oneOfOrNull("resource_kind", RESOURCE_KINDS)],
["provider_reference", sealedEitherWay("provider_reference")],
["reference_index", wordsOrNull("reference_index")],

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 Require lookup indexes for current charges

When a current/provider-backed charge omits reference_index, wordsOrNull accepts it even though provider_reference is required. The resulting money record cannot be found through idx_payment_charges_reference, so provider reconciliation or callbacks using the blind lookup code can miss it; allow a missing index only for an explicitly justified legacy shape, not for current charges.

AGENTS.md reference: AGENTS.md:L85-L86

Useful? React with 👍 / 👎.

"alert_sent_revision IS NULL OR (alerted_revision IS NOT NULL AND alert_sent_revision = alerted_revision)",
"alert_sent_at IS NULL OR (alerted_at IS NOT NULL AND alert_sent_at >= alerted_at)",
"(alert_lease_token IS NULL) = (alert_lease_expires_at IS NULL)",
`alert_lease_expires_at IS NULL OR (typeof(alert_lease_expires_at) = 'integer' AND alert_lease_expires_at >= first_observed_at)`,

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 Floor alert leases at the alert time

When first_observed_at = 1, alerted_at = 100, and alert_lease_expires_at = 50, this rule accepts the claim even though its lease expired before the alert it is meant to send. That lets the alert queue treat the claim as stale while the first sender may still believe it owns the work, risking duplicate owner alerts; a claimed alert must have an alerted_at and expire no earlier than it.

AGENTS.md reference: AGENTS.md:L85-L86

Useful? React with 👍 / 👎.

Comment on lines +70 to +71
const saysSomething = (name: string): string =>
`${isText(name)} AND length(trim(${name})) > 0`;

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 Reject every whitespace-only identifier

When an identifier or lease token consists only of tabs, newlines, or carriage returns, SQLite's one-argument trim() does not remove those characters, so saysSomething accepts the value as nonblank. This affects every column built with words, wordsOrNull, or keyWords, admitting effectively empty payment IDs, lookup codes, and worker claims; check all whitespace rather than only ordinary spaces.

AGENTS.md reference: AGENTS.md:L85-L86

Useful? React with 👍 / 👎.

`typeof(observed_at) = 'integer' AND observed_at >= 0`,
// Money can never be given back beyond what was taken. This holds whatever
// the refund is doing, so it belongs with the money rather than the runtime.
"refunded_amount IS NULL OR captured_amount IS NULL OR refunded_amount BETWEEN 0 AND captured_amount",

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 Reject refunded money in the none state

When refunded_amount is positive but refund_state = 'none', the amount check accepts the row as long as it does not exceed the captured amount. That creates contradictory money facts: code using the state can try to refund money the amount says was already returned, while reporting or reconciliation using the amount sees the opposite result. At minimum, the none state must forbid a positive refunded amount.

AGENTS.md reference: AGENTS.md:L85-L86

Useful? React with 👍 / 👎.

// be copied from a given old table per payment, so anything the upgrade
// does not know about would be a second, distinct "nowhere" that the
// unique index happily accepts.
["legacy_source", oneOfOrNull("legacy_source", LEGACY_SOURCES)],

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Require a source for every legacy charge

When origin = 'legacy' and legacy_source is null, this nullable column accepts the row and SQLite's unique index treats each null as distinct. A repeated legacy copy can therefore insert the same source charge again—especially when its lookup index is also unavailable—defeating the migration's idempotency guard and potentially duplicating recorded money. Require every legacy charge to name one of LEGACY_SOURCES, and forbid that field on current charges.

AGENTS.md reference: AGENTS.md:L85-L86

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Answering all eight findings from the last review together, because seven of them are declined for the same reason and it is a decision rather than a disagreement about the code.

The tables are being cut back to what a column is, not what a payment may say.

Six tables carried 104 rules; every other table in this site carries eight between them. SQLite cannot change a rule once rows exist — the table is rebuilt — a broken rule gives back a constraint string rather than a stack trace, and testing one needs a database. Six rounds of review on #1973 went almost entirely on these rules rather than on the code using them, and this review is seven more in the same direction. That cost is the point.

So the tables now say only a column's type, whether it may be missing, what it starts as, and which lookups stay unique. Everything about how a payment behaves lives in src/shared/payment-state/record/* as plain functions over plain data, tested without a database and changeable without rebuilding a table full of real money.

Against that, each finding:

Finding Where the rule lives now
Require provider and resource kind together record/charge.tswhatMoneyTakenHereKnows
Tie the resolved timestamp to the resolved state record/case.ts
Require lookup indexes for current charges record/charge.tsmoneyTakenHereIsComplete
Require an attempt before scheduling its retry record/decision.tsdecisionStateAgreesWithItsTries
Reject refunded money in the none state record/charge.tsrefundedTotalMatchesState, already written
Require a source for every legacy charge (P1) record/charge.tsmoneyCopiedAcrossIsBare, already written
Floor alert leases at the alert time record/case.ts

None of these is wrong as an observation. They are all correct about what the table currently permits — they are declined because the table is no longer the place that answers.

The eighth is different and is now moot. You were right that SQLite's one-argument trim() leaves tabs, newlines and carriage returns alone, so saysSomething accepted a whitespace-only identifier. That was a real gap in a real rule. saysSomething no longer exists, so the fault goes with it — a blank identifier is now the record layer's to refuse.

One rule stays on the tables, and it is worth saying why, since it is the exception that proves the rule: anything holding the buyer's details must really be text and carry a whole envelope. That check asks what actually landed in the column rather than what the code meant to put there, which is the one thing TypeScript cannot see — a type says string while the value is bytes. Three real faults on #1973 surfaced exactly that way, including one where the buyer's name, email, phone and address could have been stored as bytes no later lookup would match. It stays at the last point before the data comes to rest.

This PR will be rebuilt on that basis — the vocabularies move back to #1973 with the record layer, leaving the slice as the tables and their migration.


Generated by Claude Code

Six tables, the migration that creates them, and the words each column is
built from. Nothing writes to them yet.

The tables say only what a column is — its type, whether it may be missing,
what it starts as — and one thing more: that a column holding the buyer's
details really contains encrypted text. That last rule checks what landed
rather than what the code meant to write, which is the one thing TypeScript
cannot see, since a type says "string" while the value is bytes.

How a payment may behave is the record layer's to enforce, in code, where a
broken rule says what is wrong in words, can be tried without a database,
and can be changed without rebuilding a table already full of real money.
@stefan-burke
stefan-burke force-pushed the claude/payment-record-tables branch from b2a5bee to 1f80fa8 Compare July 30, 2026 13:42

@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: 1f80fa8cf6

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +30 to +31
export const words = (fallback?: string): string =>
withDefault("TEXT NOT NULL", fallback && `'${fallback}'`);

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 Require lookup words to be real text

When a low-level write binds a Uint8Array for a words() field, SQLite stores it as a BLOB despite the TEXT affinity. The payment_id, case resource, effect, and delivery keys built with this helper then do not match the same value bound as a string, and SQLite's unique indexes allow both BLOB and TEXT copies, potentially making child records unreachable or allowing duplicate completion work. Add a typeof(column) = 'text' constraint rather than relying on TEXT affinity alone.

AGENTS.md reference: AGENTS.md:L85-L86

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Declining — deliberately, and the repo owner made the call directly when I put this trade-off to them.

Your mechanism is right, and it is the same fault we fixed on the encrypted columns: SQLite keeps a bound Uint8Array as a BLOB under TEXT affinity, the unique index treats it as distinct from the string, and the child rows become unreachable. That is exactly why sealed starts with typeof(x) = 'text'.

The reason it does not extend to words() is what this slice is now about. These tables deliberately carry only one rule — that a column holding the buyer's details really contains encrypted text — because a rule in a table says nothing useful when it fails, cannot be tried without a database, and cannot be changed once real money is in the table without rebuilding it. The encrypted-column check is the single kept exception, on the grounds that TypeScript genuinely cannot see the difference between a string type and a bytes value.

I argued your position: a byte-bound payment_id is precisely that same blind spot, so the exception ought to cover it. Put as a straight choice, the answer was to leave the tables bare — we write these columns ourselves, we always bind strings, and trusting our own code is the point.

So this is an accepted gap rather than a fixed one, and I would rather say that plainly than dress it up. The neighbouring case that ordinary code can create — a key that is blank or only whitespace — is refused in the record layer on #1973 (allSaySomething).

Same answer on the sibling threads about keyWords and the whole-number helpers over on #1973.


Generated by Claude Code

Comment on lines +6 to +8
* Nothing writes to them yet. The code that does — the repositories, the
* runtime, and the copy that brings older payments across — follows in its own
* changes, so each arrives small enough to read.

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 Remove release sequencing from the migration comment

When the follow-up repositories and runtime land, this permanent migration comment will become false, while the explanation that the work was split into smaller changes still says nothing about how the migration operates. Keep only the migration's current purpose here and leave the temporary sequencing rationale in the PR or commit history so future readers are not told that nothing writes these tables.

AGENTS.md reference: AGENTS.md:L71-L72

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Correct, and fixed in 7c12956. The comment now says what the tables are for and nothing about when other code arrives:

The tables one payment record lives in: the payment itself, the money taken
against it, the work and messages that follow, and any problem for the owner
to settle.

Your distinction is the right one and it is the house rule here too — comments describe current code, and git history keeps the rest. "Nothing writes to them yet" was true when written and would have quietly become a lie, which is worse than saying nothing, because a future reader has no way to tell a stale comment from a current one.

The sequencing now lives only in this PR's description and the commit message, which is where it belongs.


Generated by Claude Code

wordsOrNull,
} from "./columns.ts";

/* jscpd:ignore-end */

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 Pair the duplication-ignore marker with its import block

This jscpd:ignore-end marker has no matching jscpd:ignore-start anywhere in the file, so it cannot delimit the permitted import-only exclusion and instead leaves a misleading suppression directive in production code. Add the matching import-block start before the imports, as in sessions.ts, or remove this unmatched marker.

AGENTS.md reference: AGENTS.md:L73-L73

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Correct, and fixed in 7c12956.

The opening half was lost when the tables' rules were stripped — the imports shrank, that line went with them, and the closing marker was left delimiting nothing. Restored to match sessions.ts.

Worth saying why the marker is wanted rather than just deleting the orphan, since removal was your other option: import blocks are the one place jscpd:ignore is sanctioned in this repo, and the six table files necessarily import overlapping sets of column builders. Without the pair that genuine duplication is reported at the 0% threshold, and there is no helper to extract — the imports are the duplication.

Verified: deno task cpd clean, 66 table tests pass.


Generated by Claude Code

The charges table had an import marker's closing half with no opening half,
so it delimited nothing. The migration's comment said nothing writes to
these tables yet, which stops being true as soon as something does; it now
says what the tables are for.

Copy link
Copy Markdown
Member Author

This branch was rebuilt — eight earlier review threads are now about code that no longer exists

Since this PR was opened, the repo owner decided the tables should stop carrying behaviour rules. The six tables went from 104 CHECK constraints to 14, and the 14 that remain all do one job: verify that a column holding the buyer's details really contains encrypted text — the one thing TypeScript cannot see, because a type says string while the value is bytes.

Everything else moved to the record layer in TypeScript, where a broken rule says what is wrong in words, can be tried without a database, and can be changed without rebuilding a table already full of real money.

This branch has been rebuilt on current main to match. The slice is now ~1,400 lines rather than 2,065.

What this means for the open threads

Eight threads on this PR describe faults in CHECK constraints that are gone. They were all correct about the code as it stood, and I am not dismissing them — but the specific constraint each one names no longer exists, so there is nothing to change at the line it points at. Where the underlying rule still mattered, it now lives in record/* on #1973:

the thread asked for where the rule is now
provider and resource kind required together kindMatchesProvider — checked against RESOURCE_KIND_BY_PROVIDER, so a null on either side fails
a retry after the attempt it follows "The next try comes after the try it follows"
resolved_at tied to the resolved state caseStateAgreesWithItsWork, per state
a lookup index for every current charge whatMoneyTakenHereKnows, required for money taken here
an alert lease floored at the alert time the lease now has an expiry at all, and must be paired with its token
no refunded money in the none state refundedTotalMatchesState
a source for every copied charge LEGACY_SOURCES.some(...)
whitespace-only identifiers refused allSaySomething — refuses blank, spaces, tabs and newlines

The one that has no replacement is the typeof(x) = 'text' check on plain word columns. That is an accepted gap, decided deliberately: it is answered in full on the thread above.

Two threads on the current code were fixed

  • The charges table had an import marker's closing half with no opening half. Restored in 7c12956.
  • The migration comment said nothing writes to these tables yet, which would quietly become false. It now says what the tables are for.

Checks

Run against this branch on its own, on top of current main:

  • deno task precommit passes, including the check that every export has a real user
  • every table file is at a full mutation kill rate — cases 39/39, charges 34/34, columns 24/24, decisions 25/25, sessions 50/50, completion-effects 8/8, completion-deliveries 13/13
  • no mutants are recorded as known-equivalent for these files

Generated by Claude Code

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/shared/db/migrations/schema/payments/columns.ts`:
- Around line 30-31: Update words to accept the column name and append a CHECK
constraint enforcing length(trim(<column>)) > 0 alongside TEXT NOT NULL, while
preserving fallback handling. Update every words caller in the payment schema
migrations to pass its corresponding column name so required text fields reject
blank or whitespace-only values.

In `@test/shared/db/migrations/registry.test.ts`:
- Around line 50-56: Update the migration-order assertions in
test/shared/db/migrations/registry.test.ts at lines 50-56 and 58-68 to first use
strong exact assertions confirming the prerequisite IDs
2026-07-22_maintenance_completion and 2026-07-28_note_entities are present, then
compare their positions with the dependent migrations. Preserve the existing
ordering checks while preventing missing IDs from being treated as valid index
-1 values.

In `@test/shared/db/migrations/schema/payments/decisions.test.ts`:
- Around line 39-50: Extend the accepted payment decision insert coverage near
the existing decision cases to include a valid row with claim set to NULL, while
retaining the current malformed plaintext rejection tests. Use the existing
payment_case_decisions insert pattern and assert that the nullable encrypted
claim is accepted.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 03678f47-db33-44bf-9698-35aae91b6121

📥 Commits

Reviewing files that changed from the base of the PR and between 9c41c9a and a45c668.

📒 Files selected for processing (25)
  • src/shared/db/migrations/2026-07-26_payment_records.ts
  • src/shared/db/migrations/registry.ts
  • src/shared/db/migrations/schema/index.ts
  • src/shared/db/migrations/schema/payments/cases.ts
  • src/shared/db/migrations/schema/payments/charges.ts
  • src/shared/db/migrations/schema/payments/columns.ts
  • src/shared/db/migrations/schema/payments/completion-deliveries.ts
  • src/shared/db/migrations/schema/payments/completion-effects.ts
  • src/shared/db/migrations/schema/payments/decisions.ts
  • src/shared/db/migrations/schema/payments/index.ts
  • src/shared/db/migrations/schema/payments/sessions.ts
  • src/shared/db/migrations/schema/version.ts
  • test/shared/db/migrations/2026-07-26_payment_records.test.ts
  • test/shared/db/migrations/registry.test.ts
  • test/shared/db/migrations/schema/index.test.ts
  • test/shared/db/migrations/schema/payments/cases.test.ts
  • test/shared/db/migrations/schema/payments/charges.test.ts
  • test/shared/db/migrations/schema/payments/columns.test.ts
  • test/shared/db/migrations/schema/payments/completion-deliveries.test.ts
  • test/shared/db/migrations/schema/payments/completion-effects.test.ts
  • test/shared/db/migrations/schema/payments/decisions.test.ts
  • test/shared/db/migrations/schema/payments/refuses.ts
  • test/shared/db/migrations/schema/payments/schema.test.ts
  • test/shared/db/migrations/schema/payments/sessions.test.ts
  • test/shared/db/migrations/schema/version/guard.test.ts

Comment thread src/shared/db/migrations/schema/payments/columns.ts
Comment thread test/shared/db/migrations/registry.test.ts
Comment thread test/shared/db/migrations/schema/payments/decisions.test.ts
The migration order tests compared positions with indexOf, where a name that
is not in the list at all reads as -1 and loses to every real position — so
deleting a migration these depend on would have kept them green. Each name
is confirmed present before the two are compared.

A decision's record now has a case where what was decided and what went
wrong are both missing, which the rules on those columns allow. Every
accepted row until now filled them in, so a rule that wrongly demanded them
would not have been caught.
@stefan-burke
stefan-burke added this pull request to the merge queue Jul 30, 2026
Merged via the queue into main with commit cd0cd42 Jul 30, 2026
7 checks passed
@stefan-burke
stefan-burke deleted the claude/payment-record-tables branch July 30, 2026 16:01
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.

2 participants