Add shared credit reservations on the existing wallet

SI
5d ago
+599-0

Summary

Adds the shared before-call credit reservation primitive on the existing wallet: reserve_credits_once, settle_credit_reservation and release_credit_reservation, backed by a new credit_reservations table. This is the "atomic reservation, settlement, release, and reconciliation with stable operation/idempotency IDs" item of app#2105 milestone 1 (AI accounting), which Context accounting app#2123 reuses instead of building its own.

Wallet invariant: confirmed balance = credits_usage.remaining_credits (unchanged by holds); outstanding holds = the owner's unsettled reservations; spendable = confirmed − outstanding holds. A hold is a claim, never a temporary debit, so refill, top-up and auto-top-up never read it as consumption (the constraint recorded in app#2123). Every operation locks the owner's single wallet row first (rejecting zero/multiple rows like record_credit_charge_once), so concurrent reservations cannot exceed the balance. Settlement charges through record_credit_charge_once with the same owner/operation key, so a hold and its charge share one retry-stable identity and a lost settlement reply replays as reused. Insufficient capacity is a definite insufficient receipt that writes nothing. Service role only (security invoker). The service role has SELECT/INSERT on the table and UPDATE only on the closing columns (state, credits_charged, charge_event_id, closed_at), so a hold's owner, key and amount are immutable. It has no DELETE/TRUNCATE, because reservations must be retained like charge receipts.

Refs recoupable/app#2105 (Sites epic recoupable/app#2093). Shared AI-accounting milestone consumed by recoupable/app#2123 (epic recoupable/app#2116).

Merge sequencing

database (this PR, migration 20261010210500_credit_reservations.sql) → api (recoupable/api#1026, branch claude/ctx-2105-credit-reservations, opt-in adapters with no callers). The API PR adds only new files with no caller, so its deploy order is not load-bearing.

Activation prerequisites. Releasing this migration is independent of the other open credit PRs (disjoint files and functions), but no caller may be connected until these also land:

  • database#101 + api#1009 (atomic refill/top-up writers, app#2123). On main, the API top-up (incrementRemainingCredits) and monthly refill (checkAndResetCredits) read the balance and later write an absolute value, so a settlement that commits in between is erased from the balance while its usage_events row remains. This PR's top-up/refill tests (test_atomic_top_up_and_settlement_concurrently_both_survive, test_atomic_refill_with_an_outstanding_hold_keeps_the_hold) model atomic in-place writers like #101's, not main's API paths. An uncommitted local check applied #101's migration to this fixture: a real refill_credits_to_floor kept an outstanding hold (spendable = floor − hold), and increment_credits_atomic racing a settlement kept both writes.
  • database#88 (single-wallet constraint). These functions use the same STRICT single-row FOR UPDATE as record_credit_charge_once, so they are correct before and after it; until it lands, an account with duplicate wallet rows gets a definite rejection instead of a hold or charge.

This uses its own CI workflow file, so it does not touch credit-charge-receipts.yml (which #88 edits).

Verification

  • Ran at head e05c454 (disposable local cluster, PostgreSQL 17.7, LC_ALL=en_US.UTF-8): python3 -m unittest discover -s tests -p 'test_credit_reservations.py' → 19 passed, 0 failed, 0 skipped
  • Ran at head e05c454 (PostgreSQL 15.10): same command → 19 passed, 0 failed, 0 skipped
  • Ran at head e05c454 (PostgreSQL 17.7): python3 -m unittest discover -s tests -p 'test_credit_charge_receipts.py' → 11 passed (existing suite unchanged; also 11 on 15.10 at the first commit)
  • grep -c 'GRANT EXECUTE' on the migration → 3 (service_role only); REVOKE ALL → 4 (table + 3 functions); the workflow YAML parses
  • Coverage: holds without debit or usage event; 12 concurrent reservations against one balance (exactly 10 held, 2 insufficient); 12 concurrent duplicate reservations and settlements (one hold, one charge); lost-reply replay; changed-amount/payload conflicts; settlement sharing the charge-v1 receipt; over-limit settlement rejected with the hold kept; release and its state transitions; missing reservations; per-owner isolation; top-up concurrent with settlement; refill with an outstanding hold; legacy deduct_credits_with_audit concurrent with a reservation; invalid inputs; missing/duplicate wallets; denied browser roles; denied service-role delete/truncate; denied service-role rewrites of a hold's owner, key, amount, ID or creation time (with open holds still readable for reconciliation); rollback.
  • Red → green: with the migration file absent the suite errors in setUpClass (the test requires the migration and never skips). Mutation checks against the real migration, restored byte-identical afterwards (SHA-256 verified): ignoring outstanding holds in spendable fails 4 tests, including the 12-way concurrency test; allowing settlement above the hold fails 1; making release refund the wallet like a temporary debit fails 1. Review follow-up: restoring the table-wide UPDATE grant (scratch copy) fails the new immutable-columns test (19 run, 1 failure).
  • Not verified: hosted deployment, the Supabase preview, production migration, the production database (no real wallet was read or changed), live providers or callers, and GitHub Actions results for the new workflow (pushed, not awaited).

Out of scope / known gaps

  • Legacy debit writers ignore holds. deduct_credits_with_audit, deduct_credits and the API's deductCredits/recordCreditDeduction still debit the confirmed balance, so spendable can go negative while a hold is outstanding. A test records this limitation explicitly. Moving callers (including Sites production assets) onto reservations is a later slice.
  • No hold expiry: unknown provider exposure stays held until the trusted server settles or releases it. There is no API listing of open holds yet; the service role can read credit_reservations where state = 'held' to find a hold whose key a crashed worker lost.
  • A reused receipt with status held goes to every duplicate delivery of a key, so a hold is not an execution lease. Callers must deduplicate provider dispatch by their own attempt identity; settlement charges once either way.
  • No request/pilot ceilings, pricing version, provider-expense versus customer-debit reporting, or approved failure/cancellation billing policy. Charging released or over-limit work is rejected rather than decided here.
  • No caller, price, top-up, auto-top-up change or Sites session/hosting work (milestones 2–5).
  • The fixture is the minimal legacy schema plus the actual charge-receipt migration, not a replay of every migration.

🤖 Generated with Claude Code