fix(grants): serialize concurrent same-key spends + price in integer cents (#104) - #135
Conversation
…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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
💡 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> => { |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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) }); |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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.
| function toCents(dollars: number): number { | ||
| return Math.round(dollars * 100); |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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>
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.
What you're approving
How to test
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 mergedspendreads a per-key idempotency cache, thenawaits 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'sconsumedresult, which the door'sCODE_MAPturns intorevoked(terminal). Ported #106's tiny per-key async mutex and now wrapspend,_authorize,_deny, andrevokeinthis.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.tsnow feeds the engine a cents-priced catalog (centsCatalog) and cents caps (toCents), and converts each spend'samount/remainingback to dollars for the door. The engine does integer math on whatever unit it's handed, so this is confined togrants.tsand needs no engine or public-type change. Fixes the4.9 + 4.9 + 4.9 > 14.7float-drift false refusal.Tests (
grants.test.ts). A new#104 port-forwardblock: 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