Skip to content

tx402 0.2.0 — shared fleet budget, kill switch, recipient pinning - #1

Merged
jaykomarraju merged 2 commits into
mainfrom
release/0.2.0
Aug 12, 2026
Merged

tx402 0.2.0 — shared fleet budget, kill switch, recipient pinning#1
jaykomarraju merged 2 commits into
mainfrom
release/0.2.0

Conversation

@jaykomarraju

@jaykomarraju jaykomarraju commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

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 0.2.0 spending guarantee is scoped to trusted clients. It does not protect the spending path against a compromised application — that boundary is signer-mediated authorization, and it is 0.3.0. The security page states this rather than implying it.


The normative surface

Feature A — shared atomic SpendStore + cumulative cap

Reservations, 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. BudgetExceededError gains capKind: "cumulative".
  • Store-administered limits. An operator sets the authoritative caps in the store with setBudgetLimits. reserve enforces the stored cap and rejects a larger caller-supplied cap, so a drifted or misconfigured worker cannot widen its own ceiling.
  • Backend-authoritative time. Durable stores window on the store's clock, inside the atom. This reverses 0.1.0's caller-supplied time and is precisely what stops a skewed caller from widening its window.
  • Cumulative spend is committed + exposed + reserved — the exposure term is what closes the escape below.

Feature B — kill switch

freeze a merchant scope, or the whole store with "*" on a backend that supports atomic global freeze. Every subsequent reserve is denied before a signer is reached, raising SpendScopeFrozenError. Per-scope freeze is atomic with reserve on both durable backends.

Feature C — recipient pinning

recipientPolicy pins a merchant's payout address — allowlist or trust-on-first-use. A merchant that changes its recipient mid-conversation is refused rather than paid (RecipientUnpinnedError).

Planning never mutates: evaluate does a read-only advisory check, and the authoritative assert-or-claim happens inside the atomic reserve, 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 listExposedresolveExposed. 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) and TX402_RECIPIENT_UNPINNED (RecipientUnpinnedError), both mapping to exit 3. The exit-code set is unchanged. TypeScript and Python carry identical taxonomies.

Data/admin boundary

The data-plane SpendStore and the admin SpendStoreAdmin are 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:

Store Entry points Notes
Redis tx402/redis · tx402.stores.redis Redis ≥ 7.0 floor. Windowing uses backend TIME inside the atom; Cluster hash-tags; sync and async Python adapters.
Durable Object tx402/durable-object SQLite-backed. Id-per-scope or single-coordinator; global freeze is atomic only in the single-coordinator topology.
Gateway tx402/gateway · tx402/gateway/worker · tx402.stores.gateway Fronts either backend over HTTPS and is the durable data/admin boundary. Any process, any language.

A 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 --json parity. 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 kinds spend-freeze.behavior and recipient-pin.behavior, plus extended spend-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 SpendStore or a direct caller must follow. An integration that keeps the default MemorySpendStore and sets neither maxTotal nor recipientPolicy is unaffected.

  1. Two new error codes — taxonomy 15 → 17. Adding a code is a break because callers match the taxonomy exhaustively.
  2. SpendStore contract v2 — a store must now implement expose, listExposed, isFrozen, and a required capabilities property ({ atomicGlobalFreeze }). Freeze/unfreeze are admin-plane and are not required of the data-plane object.
  3. reserve returns ReserveSpendResult, not a bare SpendReservation. Read result.reservation and result.recipientPinEstablished.
  4. Lifecycle operations take a ReservationRef, not a bare id. release, expose, and admin resolveExposed take { reservationId, policyScope, assetId }; CommitSpendInput gains policyScope / assetId.
  5. BudgetQuery.nowEpochMs is advisory for durable stores — querying a past instant works only on MemorySpendStore.
  6. commit(expired) is refused (expired-cannot-commit) where 0.1.0 permitted it. Required for cumulative-cap correctness.
  7. AsyncTx402Client.get_budget_state(...) is now async def. The sync client is unchanged.
  8. The fixed policy evaluation order gains a recipient step — observable only when recipientPolicy.mode is not "off".
  9. Python payTo is bounded at ≤ 128 characters, matching the TypeScript validation.

CHANGELOG.md carries 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.

Gate Result
typecheck · lint · format:check pass
size own-code 20.22 KiB gz (≤ 25), +core+zod 34.88 KiB (≤ 35.50)
nul-check · contract:check · cli-parity 430 files · pass · 25 scenarios
conformance check 88 vectors
docs-gen · docs:build no drift · 25 pages, every internal link re-resolved
TypeScript suite (Redis + Cluster + AOF-restart) 836 passed, 11 skipped
durable:check redis · durable:check do pass · DO 7 + gateway-over-DO 4
gateway:golden 19 requests / 14 responses / 19 methods
manifest:verify release 0.2.0, signed by the release key
Python (pytest · mypy · ruff) 783 passed, 3 skipped, 92.10 % coverage

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 publishdocs-published is a hard needs: of both publish jobs, so a dead or stale site blocks the release. The v0.2.0 tag then publishes to npm and PyPI via OIDC trusted publishing with provenance.

"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.
@jaykomarraju
jaykomarraju merged commit 2536226 into main Aug 12, 2026
14 checks passed
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