Skip to content

fix(grants): serialize concurrent same-key spends + price in integer cents (#104) - #135

Merged
dzuluaga merged 2 commits into
mainfrom
fix/104-grants-port-forward
Jul 28, 2026
Merged

fix(grants): serialize concurrent same-key spends + price in integer cents (#104)#135
dzuluaga merged 2 commits into
mainfrom
fix/104-grants-port-forward

Conversation

@dzuluaga

Copy link
Copy Markdown
Contributor

Follow-up to #112 (the merged grants feature). Fixes two correctness bugs found by the comparison in issue #104 — see the comparison comment.

In plain terms

The "spending grant" feature that just merged (#112 — a shopper approves a dollar limit once, and an AI agent spends within it while they're away) had two bugs. Both were found by comparing the merged code against the other, closed implementation of the same feature (#106), which had already solved them; this pulls those fixes forward.

  1. A safe retry could be wrongly told the grant was cancelled. If the agent asked to buy the same thing twice at almost the same moment (a double-click, or a retry fired before the first answer came back), one of the two was told "this grant was revoked — stop," even though nothing was revoked and the purchase had actually gone through once. That's the opposite of what a retry is supposed to do.
  2. A purchase that exactly fit the budget could be wrongly refused. Because amounts were tracked as decimal dollars, three $4.90 coffees against a $14.70 limit added up to $14.70000000001 inside the computer and tripped the "over budget" check. Anything priced in odd cents (a $4.99 item) could hit this.

What you're approving

  • What changes: internal only. Grant spends are now handled one-at-a-time per grant (so racing retries line up instead of colliding), and money is counted in whole cents internally instead of decimal dollars (so exact-budget math is exact). No public API change — you still pass and read plain dollar numbers exactly as before.
  • Worst case if it's wrong: a spend is refused (the safe direction) rather than a wrong charge. Money was never double-charged by either bug — the underlying ledger was already safe; these were a misleading error message and a false refusal.
  • What stays safe: the plain-dollar API, every existing behavior, and the age/limit/allowed-items rules are all unchanged. This is a pure correctness + tests change.

How to test

npm install
npm run -w @openmobilehub/credentagent-gate build
npm run -w @openmobilehub/credentagent-gate test    # 364 pass, incl. the new "#104 port-forward" block
npm run -w @openmobilehub/credentagent-storefront test
npm run lint

The new tests deliberately fire two purchases at once and confirm they collapse to a single charge with a clean retry (not a false "revoked"), and spend three $4.90 items to a $14.70 limit and confirm the third is accepted. Each safety check was also verified by reverting the fix and watching the test fail.


For reviewers — the detail

Both fixes live in packages/credentagent-gate/src/grants.ts; the engine (delegated.ts) is intentionally untouched.

Fix 1 — per-grant serialization (KeyedMutex). The merged spend reads a per-key idempotency cache, then awaits the engine draw; two concurrent same-key calls both miss the cache and reach the engine, and the loser hits the atomic single-use ledger's consumed result, which the door's CODE_MAP turns into revoked (terminal). Ported #106's tiny per-key async mutex and now wrap spend, _authorize, _deny, and revoke in this.locks.run(id, …), so the losing same-key retry finds the populated cache and replays the original outcome (ok, replayed: true), and a revoke landing while an authorize is in flight can't be overwritten back to a spendable state. Distinct-key spends serialize too and each still commits atomically. In-process only (see the known follow-up below).

Fix 2 — integer cents internally, plain-dollar API unchanged. grants.ts now feeds the engine a cents-priced catalog (centsCatalog) and cents caps (toCents), and converts each spend's amount/remaining back to dollars for the door. The engine does integer math on whatever unit it's handed, so this is confined to grants.ts and needs no engine or public-type change. Fixes the 4.9 + 4.9 + 4.9 > 14.7 float-drift false refusal.

Tests (grants.test.ts). A new #104 port-forward block: three bypass tests — concurrent same-key collapse, revoke-racing-authorize, and the $4.90×3 boundary — each verified to go red when its control is reverted (neuter the mutex → the two concurrency tests fail; feed the engine dollars → the boundary test fails), then restored. Plus two regressions (distinct-key no-overspend; double-approve seals one grant, cap holds). The merged suite had zero concurrency or money-boundary coverage, which is why these two shipped.

Known follow-up — NOT in this PR (deliberately scoped out): grants are stored in in-memory maps, so grants.retrieve(id) in a different process (a restart, or a second serverless instance — the human-away worker spec-009 FR-007 describes) finds nothing. Making grants durable across processes needs an injected store plus a compare-and-set for the cross-instance version of the same concurrency guard; that is the next step, tracked via the issue #104 comparison comment.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Y5Y1aX9ffAF26hP4fgQFi7

…eger cents

Two correctness bugs shipped in the just-merged grants feature (PR #112), both found by
comparing against the closed alternative implementation (PR #106) and reproduced against
the merged code. This ports #106's fixes forward with NO public API change — additive,
plain-dollar surface unchanged.

1. Concurrent same-key spends were misreported as "revoked". Two spend() calls with the
   SAME idempotencyKey that overlap in time both missed the in-memory idempotency cache
   and reached the engine; the loser hit the atomic single-use ledger's "consumed" branch,
   which the spend door maps to "revoked"/terminal — telling an unattended agent the grant
   is dead when nothing was revoked and the purchase actually succeeded once. A per-grant
   async mutex now serializes each grant's spend + lifecycle transitions, so a losing retry
   replays the original outcome cleanly (ok, replayed:true). Money was never double-charged
   (the ledger is atomic); the bug was the misreport + broken retry-safety under concurrency.

2. Exact-budget spends on non-round prices were falsely refused. Pricing ran in
   floating-point dollars, so 4.9 + 4.9 + 4.9 === 14.700000000000001, which exceeds a $14.70
   budget and refused the third spend with budget-exceeded. The engine now receives the
   catalog + per-grant caps in integer cents and its amounts are converted back to dollars at
   the door, so every cap comparison is exact. The public API stays plain dollars (no Money
   type — the spec-009 FR-005 decision stands).

Adds the concurrency + money-boundary tests the merged suite lacked (which is exactly why
these shipped): three load-bearing bypass tests (each verified RED with its control reverted,
then restored) plus two regressions. delegated.ts — the unit-agnostic engine — is untouched.

Known follow-up, NOT in this PR: grants are in-memory only, so "authorize now / spend later
from a worker" (spec-009 FR-007) holds only within one process; a shared store + CAS is the
durable multi-instance step (see the issue #104 comparison comment).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Diego Zuluaga <dfzuluaga@gmail.com>
@vercel

vercel Bot commented Jul 26, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
credentagent-demo Ready Ready Preview, Comment Jul 28, 2026 2:25am

@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: ec2ac4aa83

ℹ️ 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".

// for the loser — never a spurious `revoked` (which is what the engine's lost atomic
// single-use looks like out of order). Distinct-key spends serialize too and each commits
// atomically. (Issue #104, fix 1 — ported from the closed PR #106.)
this.locks.run(rec.id, async (): Promise<SpendDoor> => {

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 Let revocation interrupt an in-flight spend

Do not hold the per-grant mutex across rec.engine.spend(): when revoke() is called while that asynchronous operation is in flight, it queues behind the spend and cannot update the revocation ledger until after the draw commits. This directly breaks the revoke-wins requirement in specs/009-ap2-mandate-chain-dx/spec.md:136-140, which requires a spend that settles after revocation to be refused; serialize the idempotency-cache transition without preventing revocation from reaching the engine concurrently.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 4bd6e47. The engine ledger revoke now runs immediately, outside the per-grant mutex, so a spend already in flight hits it at its atomic settle-time re-check and is refused (revoke-wins, spec §136–140). The status flip + the authorize-race re-revoke stay serialized in the mutex, and #135's same-key serialization is unchanged. New bypass test: start a spend, revoke mid-flight, assert it is refused (code: revoked) — verified RED when the ledger revoke is moved back inside the mutex, then restored.

this.gate ??= new DelegatedGate({ catalog: this.deps.catalog });
// The engine runs in integer cents (fix 2): re-price the catalog once here, convert the
// per-grant caps at authorize, and convert each spend's amount back to dollars for the door.
this.gate ??= new DelegatedGate({ catalog: centsCatalog(this.deps.catalog) });

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 Keep cents pricing synchronized with the configured catalog

Preserve live catalog repricing when constructing the cents-priced gate. centsCatalog() creates a detached snapshot and this.gate retains it forever, whereas DelegatedGate.buildCatalog() previously read the original catalog record on every order. If the host changes an in-memory price after the first grant is created—for example, coffee rises from $10 to $100—a later spend is still priced at $10 and can pass a sealed $30 cap even though the configured price source now says $100; additions to the catalog similarly continue throwing as unknown items.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 4bd6e47. Replaced the centsCatalog() snapshot with a live cents view (a Proxy the engine reads per spend), restoring the pre-#135 per-read behaviour — an in-memory re-price, or a newly-added item, is honoured at the next spend, and the sealed cap is enforced against the live price. New bypass test re-prices coffee $18→$25 (next spend prices $25), then →$50 (now refused per-spend-exceeded against the $40 cap) — verified RED with a snapshot, then restored.

Comment on lines +136 to +137
function toCents(dollars: number): number {
return Math.round(dollars * 100);

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 dollar values that cannot be represented in cents

Validate unrepresentable dollar inputs instead of silently changing their value. The public catalog and grant options still accept arbitrary numbers, so a configured price of 0.006 is now returned and enforced as $0.01, while 0.004 becomes zero and is refused as invalid-amount; binary rounding also turns values such as 1.005 into $1.00. Either reject amounts with sub-cent precision at creation/configuration time or use a conversion that preserves the documented input semantics.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Decision: reject (per the DX rubric — fail fast, never silently change a configured value; matches the closed #106's precedent). toCents() now throws on sub-cent precision at the earliest point — grant caps at create(), catalog prices when they are priced — instead of rounding. Fixed in 4bd6e47 with a bypass test (a perSpend: 0.006 cap rejects at create; a $0.006 catalog price rejects when spent) — verified RED with silent rounding, then restored.

…cents catalog, sub-cent reject

Three follow-ups on PR #135 from the automated Codex review.

1. P1 — revoke-wins mid-spend. #135 wrapped revoke() in the per-grant mutex, so a revoke
   issued while a spend was in flight QUEUED behind that spend and could not reach the
   engine's revocation ledger until after the draw committed — the spend settled after
   revocation, which spec 009 FR §136-140 requires be refused. The ledger revoke now runs
   IMMEDIATELY, outside the per-grant queue, so an in-flight spend's atomic settle-time
   re-check sees it and refuses; the status flip + the authorize-race re-revoke stay
   serialized. The same-key serialization from #135 is unchanged.

2. P1 — live catalog re-pricing. #135's centsCatalog() snapshotted prices at first engine
   construction, so a host that re-priced an item in memory kept spending at the stale
   price (and a sealed cap was enforced against it). Replaced with a live cents VIEW (a
   Proxy) the engine reads per spend, restoring the pre-#135 per-read behavior — a
   re-price, or a newly-added item, is honored at the next spend.

3. P2 — sub-cent inputs. toCents() silently rounded ($0.006 -> $0.01, 1.005 -> $1.00).
   Per the DX rubric it now REJECTS sub-cent precision with a clear error at the earliest
   point — grant caps at create(), catalog prices when they are priced — rather than
   silently changing the value.

Each fix has a load-bearing bypass test (verified RED with its control reverted, then
restored): a spend refused by a revoke landing mid-flight; a re-price honored at the next
spend with the cap enforced against the live price; sub-cent rejected at config and at
pricing. delegated.ts stays untouched; the public API stays plain dollars.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Diego Zuluaga <dfzuluaga@gmail.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