Add opt-in credit reservation adapters for AI work

SI
5d ago
+629-0

Summary

Adds opt-in server adapters for the shared credit reservation primitive (app#2105 milestone 1, AI accounting), which Context accounting app#2123 reuses: reserveCreditsOnce, settleCreditReservation and releaseCreditReservation in lib/supabase/credit_reservations, with Zod input validation in lib/credits/reservations. A trusted server can now hold an estimated maximum before paid AI work, settle the actual charge exactly once afterwards, or release an unused hold, all against the existing wallet and usage ledger. There is no second wallet or multiplier.

  • Inputs are validated before the wallet is touched. insufficient is a definite receipt (nothing held), so a future caller can return a machine-readable 402.
  • Settlement reuses validateCreditCharge, the charge-v1 receipt identity and CreditChargeNeedsReconciliation, because a lost settlement reply may have committed.
  • Hold and release failures raise CreditReservationNeedsReconciliation. In both cases the caller keeps the same operation key and replays the call.
  • A returned database error with SQLSTATE 22023 (every rejection the reservation and charge functions raise, e.g. released/settled hold, settlement above the hold, identity conflict, missing reservation, unavailable or ambiguous wallet) was rolled back, so it throws CreditReservationRejected with the fixed database reason instead. A caller can map it to a definite 402/409 rather than replaying an identical call forever.
  • The adapters never retry, mint keys, call providers or trigger auto-top-up.
  • No existing file changes, and nothing calls these adapters yet.
  • A hold is not an execution lease: every duplicate delivery of a key gets reused/held, so callers must deduplicate provider dispatch by their own attempt identity (documented in the README).

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 (recoupable/database#115, migration 20261010210500_credit_reservations.sql) → api (this PR). These adapters have no callers, so deploying first would not break anything. Do not connect a caller until the migration is released and its grants are verified, and recoupable/database#101 + recoupable/api#1009 (atomic refill/top-up writers) and recoupable/database#88 (one wallet per account) have landed: on main, incrementRemainingCredits and checkAndResetCredits write an absolute balance read earlier, which would erase a settlement committed in between. This PR does not overlap recoupable/api#1009 (app#2123 atomic refill/top-up writers), because it changes no existing file and appends nothing to lib/credits/charges/README.md. Its docs live in a new lib/credits/reservations/README.md. It touches no Context, MCP, workflow or HTTP files, so there is no docs contract change.

Verification

  • Ran at head ace711d0: pnpm exec vitest run lib/supabase/credit_reservations lib/supabase/credits_usage lib/credits lib/stripe → 99 files, 515 tests passed, 0 failed (the 3 reservation files now have 60 tests)
  • Ran at head ace711d0: pnpm exec vitest run lib/context → 67 files passed, 8 skipped; 326 tests passed, 8 skipped
  • Ran at head ace711d0: pnpm exec eslint on the 11 new .ts files → clean; pnpm exec prettier --check on them plus the README → clean
  • Ran at head ace711d0: pnpm exec tsc --noEmit → exit 2, 0 errors in the new files. The repository has 199 errors in untouched files: 198 in test files and 1 for the missing generated module lib/mcp/extension/generated-ui. Nothing imports the new files.
  • Red → green: all three test files failed first on the missing modules (Cannot find module '../reserveCreditsOnce', '../settleCreditReservation', '../releaseCreditReservation'), then passed 48/48 after implementation. Review follow-up: with the 22023 detection removed from the three adapters, the new definite-rejection tests fail (60 run, 3 failed); restored, 60/60 pass.
  • Real concurrency/rollback behavior is covered by the database PR's 19 transaction tests on PostgreSQL 15/17. The tests here are mocked adapter tests.
  • Not verified: hosted deployment, the production database or Supabase preview, live providers, Stripe or any caller, production charging/authorization, and GitHub Actions results (pushed, not awaited). Generated database.types.ts was not regenerated; each RPC is narrowly typed at the access layer like recordCreditChargeOnce.

Out of scope / known gaps

  • No caller is wired. Sites production assets, research, content and x402 still use the read-modify-write deductCredits, and chat uses recordCreditDeduction. Neither consults holds, so spendable can go negative while a hold is outstanding. Migrating them is a later slice.
  • checkCreditsAvailable still reads the confirmed balance, not spendable credit.
  • No API listing of open holds (the service role can read credit_reservations where state = 'held').
  • No request/pilot ceilings, hold expiry, pricing version, provider-expense versus customer-debit report, or approved failure/cancellation billing policy.
  • settleCreditReservation does not call maybeAutoTopUp, and no top-up behavior changes.
  • Sites session/hosting/top-up/delivery milestones 2–5 are untouched.

🤖 Generated with Claude Code


Summary by cubic

Adds opt-in server adapters for the shared credit reservation primitive: reserveCreditsOnce, settleCreditReservation, and releaseCreditReservation in lib/supabase/credit_reservations, with Zod input validation in lib/credits/reservations. A trusted server can now hold an estimated maximum before paid AI work, settle the actual charge exactly once afterwards against the existing wallet, or release an unused hold. The current preflight only reads the confirmed balance, so concurrent requests can each pass it.

Inputs are validated before the wallet is touched. insufficient is a definite receipt (nothing held) so callers can return a machine-readable 402. Lost replies and invalid receipts raise CreditReservationNeedsReconciliation; settlement reuses CreditChargeNeedsReconciliation and the charge-v1 receipt identity because a lost settlement reply may have committed. The adapters never retry, mint keys, call providers, or trigger auto-top-up.

No existing files change and nothing calls these adapters yet. Deployment is safe on its own, but do not connect a caller until the database migration (20261010210500_credit_reservations.sql) is released and its grants are verified.