feat: share the spend ledger and error-body parsing - #9
Merged
Conversation
Both service SDKs carried their own copy of each, under different names for the same function, and both drifted — the cross-repo audit that motivated this found a concurrency hole in one client's cumulative cap and a missing fail-closed check in the other, and the error parser drifted again during that very remediation. Core already owned SpendCapError while the logic that throws it lived downstream. SpendLedger carries the per-call and cumulative bounds plus the synchronous reservation that makes the cumulative one hold under concurrency; assertFiniteUsd makes a malformed cap fail closed; parseErrorBody type-checks an untrusted response body, sharing the parsing while each SDK keeps its own error class. Writing the tests surfaced a latent defect present in both clients: cumulative spend accumulates float error, so a spend landing exactly on a cap was refused. Absorbed with a sub-atomic slack, matching the per-op price slack the SDKs already apply, and pinned by regressions that also prove ten times the slack is still refused. Additive only; nothing consumes it until a release lets the clients adopt.
Minor bump: the new exports are additive and no existing export changes, so a consumer floating ^0.2.x is unaffected until it opts in. Documents the float-accumulation fix, which reaches the service SDKs as they adopt.
maxSpendUsd bounds what a single operation may cost; pre-buying $20 of credit is not a $20 operation, so a deposit or top-off has to be able to opt out of that one bound. The cumulative cap still applies — real money still leaves the wallet — so this narrows one bound, never both. Without it the ledger could not cover a service SDK that sells prepaid credit, which is the shape the agentkv client already needs.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Extracts the two things both service SDKs had duplicated — and had already drifted on — into the platform SDK that owns the types they depend on.
Why here
Core defined
SpendCapErrorwhile the logic that throws it lived in both clients (5 throw sites each). A 2026-07-31 cross-repo audit found two holes of the same shape, each being "one client has the guard, the other doesn't":They stayed divergent partly because the same function had a different name in each repo (
assertCapOptionvsassertFiniteUsd), so grepping one never finds the other. The error-body parser drifted again during that remediation: hardening landed in one client and not the sibling.What
SpendLedger— per-call and cumulative bounds, with the synchronous reservation that makes the cumulative one hold under concurrency, plus an idempotent release. Caps validated at construction.assertFiniteUsd— a malformed cap fails closed. The dangerous case isNaN:usd > NaNis false, so an unchecked NaN cap doesn't merely fail to bind, it silently disables the comparison while still reading as configured.parseErrorBody— type-checks every field of an untrusted response body.JSON.parsereturnsanyand a cast asserts nothing at runtime; a non-stringcodesilently breaks everye.code === "…"comparison callers dispatch on. Only the parsing is shared — each SDK still constructs its own error class, since that identity is what callersinstanceof.All additive. No existing export changes.
A bug found while extracting
Writing the tests surfaced a latent defect that exists in both clients today: cumulative spend is accumulated USD floats, so
$0.005settled plus a$0.004op is0.009000000000000001, and a bare<=refuses a spend landing exactly on a$0.009cap.SpendLedgerabsorbs that with a sub-atomic slack — one atomic USDC, the same magnitude and reasoning the SDKs already apply to a quoted price — so it cannot admit a spend anyone could actually be charged. Three regression tests pin it, including that ten times the slack is still refused.Adoption
Nothing consumes this yet. The clients can only adopt after a core release, so this ships first and the two client PRs follow — deliberately, so the extraction can be reviewed on its own rather than tangled with two migrations.
Gate:
biome ciclean,tsc --noEmitclean, 144 tests passing.