tx402 0.2.0 — shared fleet budget, kill switch, recipient pinning - #1
Merged
Conversation
"Unquestionably safe, phase 1a." 0.2.0 turns the per-process spend guardrails into a fleet control plane: many cooperating agents against one wallet share one authoritative budget, spending can be frozen, and no merchant can silently redirect a payment. What it does not yet claim — and the security model says so plainly — is protection of the spending path against a compromised application. That needs signer mediation and arrives in 0.3.0. Added - A shared, durable SpendStore, behind three reference adapters that each sit off the size-gated core path: Redis (tx402/redis, tx402.stores.redis, sync and async), a Cloudflare Durable Object (tx402/durable-object, SQLite-backed), and a capability gateway (tx402/gateway, tx402.stores.gateway) that fronts either backend and is the durable data/admin boundary. A fleet sharing one store shares one per-hour rate, one cumulative ceiling, one freeze switch, and one set of recipient pins. - A cumulative cap: policy.maxTotal / Policy(max_total=...) bounds lifetime spend, not just the rolling hour, and never resets on its own. An operator may administer the authoritative caps in the store so a drifted worker cannot widen them. - A kill switch: freeze a merchant scope, or the whole store with "*" on a backend with atomic global freeze, and every later reserve is denied before a signer is reached. - Recipient pinning: allowlist or trust-on-first-use, so a merchant that changes its payout address mid-conversation is refused rather than paid. - Durable ambiguous-payment accounting: a maybe-settled payment is fenced durably before transmission and counts against both caps until an operator reconciles it. It never escapes on a TTL. - Five operator CLI verbs — freeze, unfreeze, budget, pins, rotate-recipient — at byte-for-byte TypeScript/Python parity, with a data/admin credential split. - Eighty-eight frozen conformance vectors, up from seventy-three, executed identically by both languages and, for the durable stores, behind the gateway in CI. Breaking Every break is one a custom SpendStore or a direct caller must follow. An integration that keeps the default MemorySpendStore and sets neither maxTotal nor recipientPolicy is unaffected. Two new error codes take the taxonomy from fifteen to seventeen; SpendStore reaches contract v2 with a required capabilities property; reserve returns a ReserveSpendResult; lifecycle operations take a ReservationRef rather than a bare id; durable stores window on backend time, making BudgetQuery.nowEpochMs advisory for them; commit(expired) is refused; AsyncTx402Client.get_budget_state is now async; the fixed policy evaluation order gains a recipient step; and Python payTo is bounded at 128 characters to match TypeScript. CHANGELOG.md has the full inventory and the migration guide walks each one. The bundled release manifest is re-signed at 0.2.0 with the release key.
…es not ship Two changes on top of the 0.2.0 release commit. Documentation deploys deliberately, not from CI. `.github/workflows/docs.yml` is removed. It existed to deploy a site it never once deployed — one run in its whole history, cancelled — while the site itself has been served all along by Cloudflare Pages' Git integration. Adding the missing token would have created a second deploy path onto the same Pages project, racing the first. Instead: pnpm docs:deploy builds the site, deploys `docs/dist` with wrangler, and reads the result back over the public internet with the same probe the release gate uses. Deploy, confirm, then tag. Nothing is lost. CI's `Docs site` job still runs `docs:check` and `docs:build` on every push and pull request, so a broken page or a stale generated page still fails. And the release workflow's `docs-published` job still probes the live site as a hard prerequisite of both publish jobs, so a stale site still stops a tag before either registry is touched. References to internal-only documents are gone. This repository does not ship the planning, specification or decision records, so every citation of them was a pointer a reader could not follow. 794 are removed: all-citation parentheticals throughout, and by-hand rewrites everywhere the file name was load-bearing in the sentence. Every reference that named an internal FILE — the class that invites a 404 — is gone except eight, which are the guards that keep such references out of the surfaces a reader actually reads; those name the files as assertion data, so removing them would delete the protection. No behaviour changed, and that is verified rather than asserted: the TypeScript emitted with comments stripped is byte-identical before and after, and every Python module's syntax tree, docstrings excluded, is identical. Three conformance vector descriptions were reworded, so those three re-hash; the other 85 are untouched and the suite is still 88 vectors with the same milestone split.
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.
tx402 0.2.0
The 0.2.0 slice: Feature A (shared atomic
SpendStore+ cumulative cap), Feature B (kill switch), Feature C (recipient pinning), plus durable ambiguous-payment exposure accounting. Features D (idempotent intents) and E (signer-mediated authorization) are deliberately deferred to 0.3.0.0.1.0 enforced spend policy per process. Run ten agents against one wallet and you had ten independent budgets and no ceiling. 0.2.0 makes the budget authoritative and shared.
The normative surface
Feature A — shared atomic
SpendStore+ cumulative capReservations, commits, the rolling hour, and a new cumulative ceiling all resolve against one authoritative store, atomically.
policy.maxTotal/Policy(max_total=…)bounds lifetime spend against a scope. It never resets on its own; the rolling hour still applies independently.BudgetExceededErrorgainscapKind: "cumulative".setBudgetLimits.reserveenforces the stored cap and rejects a larger caller-supplied cap, so a drifted or misconfigured worker cannot widen its own ceiling.committed + exposed + reserved— the exposure term is what closes the escape below.Feature B — kill switch
freezea merchant scope, or the whole store with"*"on a backend that supports atomic global freeze. Every subsequentreserveis denied before a signer is reached, raisingSpendScopeFrozenError. Per-scope freeze is atomic withreserveon both durable backends.Feature C — recipient pinning
recipientPolicypins a merchant's payout address —allowlistor trust-on-first-use. A merchant that changes its recipient mid-conversation is refused rather than paid (RecipientUnpinnedError).Planning never mutates:
evaluatedoes a read-only advisory check, and the authoritative assert-or-claim happens inside the atomicreserve, on the selected viable route. That ordering is what closes the rotation TOCTOU and stops an unpayable route from poisoning the pin. A legitimate rotation is an operator action with freeze-before-rotate semantics.Ambiguous-payment exposure accounting
A maybe-settled payment is fenced durably, before transmission, and a fence-write failure aborts the send with nothing transmitted. The exposed reservation counts against both caps until an operator reconciles it via
listExposed→resolveExposed. It never escapes on a TTL. The failure mode is "an operator has a task", never "budget silently freed".Error model — taxonomy 15 → 17
TX402_SPEND_FROZEN(SpendScopeFrozenError) andTX402_RECIPIENT_UNPINNED(RecipientUnpinnedError), both mapping to exit3. The exit-code set is unchanged. TypeScript and Python carry identical taxonomies.Data/admin boundary
The data-plane
SpendStoreand the adminSpendStoreAdminare separate surfaces with separate credentials. A data-plane worker can reserve and read but cannot freeze, re-pin, or raise a limit. Enforced durably by the gateway, and approximately by a Redis key-pattern ACL or the Durable Object's in-object admin-token verification.Reference durable stores
Three, each behind an optional import off the size-gated core path — the core measurement is unchanged at 20.22 KiB gzipped own-code:
tx402/redis·tx402.stores.redisTIMEinside the atom; Cluster hash-tags; sync and async Python adapters.tx402/durable-objecttx402/gateway·tx402/gateway/worker·tx402.stores.gatewayA normative store-config DSN, namespace, and admin/data credential split make the CLI implementable against all three.
CLI and observability
Five operator verbs —
freeze,unfreeze,budget,pins,rotate-recipient— at byte-for-byte TypeScript↔Python--jsonparity. Four new request-path events —payment.exposed,spend.frozen,recipient.pinned,recipient.rejected— extending the closed event-name set, each redaction-safe by construction.Conformance — 73 → 88 vectors
Frozen, and executed identically by both languages:
M0:30 M1:12 M2:6 M3:7 M4:4 M5:6 M6:23. New vector kindsspend-freeze.behaviorandrecipient-pin.behavior, plus extendedspend-ledger.behavior. The durable stores run the shared contract suite in both languages, including behind the gateway, in CI.Breaking
Every entry is a change a custom
SpendStoreor a direct caller must follow. An integration that keeps the defaultMemorySpendStoreand sets neithermaxTotalnorrecipientPolicyis unaffected.SpendStorecontract v2 — a store must now implementexpose,listExposed,isFrozen, and a requiredcapabilitiesproperty ({ atomicGlobalFreeze }). Freeze/unfreeze are admin-plane and are not required of the data-plane object.reservereturnsReserveSpendResult, not a bareSpendReservation. Readresult.reservationandresult.recipientPinEstablished.ReservationRef, not a bare id.release,expose, and adminresolveExposedtake{ reservationId, policyScope, assetId };CommitSpendInputgainspolicyScope/assetId.BudgetQuery.nowEpochMsis advisory for durable stores — querying a past instant works only onMemorySpendStore.commit(expired)is refused (expired-cannot-commit) where 0.1.0 permitted it. Required for cumulative-cap correctness.AsyncTx402Client.get_budget_state(...)is nowasync def. The sync client is unchanged.recipientPolicy.modeis not"off".payTois bounded at ≤ 128 characters, matching the TypeScript validation.CHANGELOG.mdcarries the full inventory; the migration guide walks each one.Verification
Green locally at 0.2.0 on Node 22 with a real Redis standalone + 3-master cluster, and green on all 14 CI checks here.
typecheck·lint·format:checksizenul-check·contract:check·cli-parityconformance checkdocs-gen·docs:builddurable:check redis·durable:check dogateway:goldenmanifest:verifypytest·mypy·ruff)The curated tree was additionally installed and built from scratch on its own, so this repository is proven self-sufficient rather than assumed to be.
Release order after merge
Documentation deploys and is live-verified before publish —
docs-publishedis a hardneeds:of both publish jobs, so a dead or stale site blocks the release. Thev0.2.0tag then publishes to npm and PyPI via OIDC trusted publishing with provenance.