Skip to content

Store: declared transfers between records (#902) - #926

Merged
jimhoyd merged 1 commit into
mainfrom
claude/store-transfers
Sep 29, 2026
Merged

jimhoyd merged 1 commit into
mainfrom
claude/store-transfers

Conversation

@jimhoyd

@jimhoyd jimhoyd commented Sep 29, 2026

Copy link
Copy Markdown
Collaborator

Implements #902 item 2: a declarative transfer between records that preserves the collection's sum. Before this, it needed a trusted StoreExports.transaction.

Declaration

  • collections.<c>.transfers.<name>: {amount, min?, members?}, served at POST <mount>/transfers/<name> with body {from, to, amount}.
  • The body is checked by a generated schema: the two ids must differ, and the amount is a positive whole number up to 2^53−1. Anything else answers 422 invalid_transfer.
  • Whole numbers only: the amount property must be a required integer, so values are minor units and nothing is rounded.
  • Activation refuses: an amount property that is an increment or appears in intervals, and transfers on a membership collection. At most 8 transfers per collection.

Execution

Everything runs in one BEGIN IMMEDIATE transaction, and every refusal writes nothing. The checks run in this order:

  1. members gate: 403
  2. retry-key claim: replay, or 422 if reused
  3. from is in the caller's scope: 404
  4. If-Match on from: 412
  5. floor min (default 0): 409 insufficient_balance
  6. to exists: 404
  7. schema and safe-integer range: 409 transfer_limit, with no issue list
  8. record size: 413
  9. both rows are written, with two store.record.transferred audit events (names and ids only, never amounts)

StoreRecords.transfer and StoreTransactionRecords.transfer follow the same rules.

Authorization model

  • Owned collection:
    • A caller debits only a record they own; anyone else's record gets the same 404 as a missing id.
    • They may credit any record.
    • to is returned only when the caller owns it.
    • The floor is checked before to is looked up, so ids can't be probed without funds.
  • Shared collection: anyone who reaches the mount may transfer, the same as shared transitions. The docs say to guard the route or use members.
  • members: narrows who may transfer; it never allows debiting someone else's record.
  • Funding: a transfer never creates value. A members-gated transfer with a negative min acts as an issuer, whose negative balance is the supply outstanding.

Evidence (macOS, Node 26), on main after #925

  • transfers.test.ts (9 tests):
    • 200 interleaved transfers, in one process and across four worker-thread connections, keep the total at 400 with no balance below its floor;
    • every refusal writes nothing, checked by row counts;
    • the authorization cases, including issuer supply;
    • idempotency replay without a double debit;
    • audit atomicity through an injected trigger failure: 503 with nothing left behind, then a clean retry;
    • host-transaction rollback, and the activation refusals.
  • openapi.test.ts: the document validates, and a live answer validates against Store<C>Transferred.
  • npm run verify and npm run test:package pass. The store workspace has 172 tests.
  • Budgets: store 124 / 475 KiB, core 989 / 3885 KiB, with measured comments.

Limits

  • Holds (reserving part of a balance and settling later) still need a host transaction.
  • No transfers across collections or between properties.
  • transfer_limit reveals that a recipient is near its maximum; this is documented.

🤖 Generated with Claude Code

- transfers.<name>: {amount, min?, members?} on a collection serves
  POST <mount>/transfers/<name> with {from, to, amount}: one BEGIN IMMEDIATE
  transaction subtracts amount from from's integer property and adds it to
  to's, with both audit events (store.record.transferred, side/counterpart)
  and the Idempotency-Key claim, so the sum never changes.
- Body validated by a generated schema (two distinct record ids, a positive
  whole amount <= 2^53-1); anything else, a fraction included, is
  422 invalid_transfer. Below min (default 0) is 409 insufficient_balance;
  outside the property schema or safe integers 409 transfer_limit (no issue
  list); every refusal writes nothing. If-Match on from; replay answers both
  records as they are now.
- Authorization: owned collection debits only the caller's own record and
  may credit any owned record (to shown only when the caller owns it); the
  floor is checked before to is read, so ids cannot be probed for free.
  Shared: anyone reaching the mount. members gates who may run it; a gated
  transfer with a negative min is an issuer (double entry).
- Amount property: required integer with integer default, not an increment
  or intervals property; a readOnly property a transfer moves is accepted.
- StoreRecords.transfer and StoreTransactionRecords.transfer; OpenAPI path
  with Store<C>Transfer/Store<C>Transferred.
- Tests: 200 interleaved transfers conserve the total in one process and
  across four connections, overdraft/412/422 write nothing, owned/shared/
  members authorization, same-record, retry replay, trigger-injected audit
  rollback, activation refusals, OpenAPI validity and served answer.
- STORE.md "Declared transfers" section and "What is not covered", README,
  llms, CHANGELOG, package budgets re-measured.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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