Core design
The implemented request pipeline, execution plans, payment lifecycle, component contracts, and recovery model.
Tollstile separates what was offered (quote), what the payer authorized (authorization), each economic effect (charge), and whether the service exists (fulfillment). Rails implement payment protocols; policies decide who pays; adapters connect the runtime to a framework.
This page describes the current implementation. SPEC.md defines the component contract; DESIGN.md explains the model. The TypeScript contracts define the actual fields. Planned concepts such as escrow are not executable flows.
Request pipeline
| Stage | Responsibility | Result |
|---|---|---|
| Request | Adapter normalizes HTTP or MCP input, principal, resource, request ID, and idempotency key | Context |
| Policy | Core tries access policies in order | Grant, balance reservation, rail payment, or denial |
| Price | Merchant configuration supplies a fixed amount, maximum, or computed price | Money and variable flag |
| Negotiation | Planned rails offer terms; core signs a quote; rails render challenges | 402 payment challenge |
| Authorization | Rail verifies the proof and returns canonical payer identity and stable proof ID | Stored authorization |
| Execution | Requirements pass; core atomically reserves capacity on a charge | Handler admitted |
| Metering | Handler marks fulfillment and, for upTo(), actual usage | Final amount and optional result reference |
| Settlement | Core records intent, calls the rail, and records the known result or ambiguity | Charge state |
| Receipt | Adapter acts on Completion | Output with receipt, output without receipt, or denial |
Issuing a price or quote does not write payment records. A balance reservation or verified rail proof creates records; requirement nonce claims are separate writes. Core uses Web-standard requests and has no framework or protocol imports.
Money, offers, and quotes
Money is { currency, micros: bigint }: six decimal places, integer arithmetic. $0.04 is 40000n USD micros. A price currency is distinct from a settlement asset. Each offer records an asset, network, integer asset amount, flow, and explicit par or merchant-supplied rate basis.
Quotes are signed with HMAC-SHA256, never stored, and expire after quoteTtlMs (five minutes by default). Their fields include resource, request commitment, price, variable flag, offers, nonce, and issuance/expiry dates. Rails with quote support carry the token inside their protocol and return the opened quote during verification.
A single-use proof pays its quoted price and must match the selected commitment. Computed prices default to method/resource plus path/query and body bytes, or MCP tool and canonical arguments. Static prices default to method/resource only. A custom commitment covers the fields the merchant selects. Reusable authorizations pay the current request's price against remaining capacity. See Quotes.
Execution plans
toll.price() compiles an ExecutionPlan. gate.plan exposes it as data; toll.explain(gate) formats it for people.
| Plan field | Meaning |
|---|---|
route, pricing, commitment | Route name; fixed, up-to, or computed pricing; route, request, or custom quote binding |
access, requirements | Names in configured order |
rails | Per rail: flow, settlement timing, authorization kind, fixed/up-to amounts, and handler-failure behavior |
excluded | Rail name, missing capability (needs), and explanation (reason) |
A rail without lookup, or one declaring upfront without refunds, is invalid. A valid rail incompatible with this route is excluded. If no rail remains, defining the route throws CAPABILITY_MISSING. Core only verifies and offers planned rails. Computed upTo() results filter further to plans supporting variable settlement; if none remain, that request throws CAPABILITY_MISSING.
The default prefers authorization; static upTo() requires authorization and variable amounts. Computed prices require quote support. escrow throws CONFIG_INVALID. See Rails.
Authorizations and charges
An authorization is identified by a hash of rail and proof ID. It records payer, kind, limit (nullable), consumed/reserved amounts, quote ID, expiry, and rail data.
single: at most one charge that has not been released. Reusing a released proof is possible if verification still accepts it.reusable: multiple charges within capacity and expiry. Without a key, another admitted request creates another charge.
A charge has independent payment and fulfillment states:
payment: reserved → settling → settled → refund_pending → refunded
↓ ↓ refund ambiguity ↘ unknown
released failed / unknown
fulfillment: pending → running → completed
↓
failedThe transition tables define legal moves, including recovery out of unknown. Updates compare both axes before changing them. Reservation accounting and the transition history change atomically with the charge. pending: "settle" | "refund" distinguishes ambiguous operations.
Creating a charge reserves capacity. Settlement commits it; release or refund removes its accounting contribution. A charge in a different currency or outside the ledger's signed 64-bit amount range is refused. See Ledger.
Flows and fulfillment
| Flow | Order | Failure before explicit fulfillment |
|---|---|---|
authorization | Reserve → run → meter → settle | Release |
upfront | Reserve → settle → run | Refund |
Payments already moved at verification are recorded as upfront regardless of the declared plan. If such a rail cannot refund, failed fulfillment remains settled/failed and is reported for merchant action.
payment.fulfill() marks the service as existing. A later handler failure does not undo it. On variable prices, fulfill({ amount }) must supply an amount between zero and the maximum. Zero releases; success without fulfillment also releases and emits FULFILLMENT_MISSING. Fixed prices cannot be reduced through fulfill.
fulfill({ resultRef }) records a non-secret reference of 1–1024 characters. Tollstile does not store the result itself or guarantee response delivery.
Completion and error boundaries
Adapters complete each admitted call once and act on its result:
| Completion | Adapter behavior |
|---|---|
settled | Attach the rail receipt and serve the output |
rejected | Withhold output and return the settlement denial |
unknown | Serve output without a receipt; reconcile later |
none | Serve the handler's own result |
For a post-handler rejection, a fresh challenge is created only when the request body remains available. Otherwise the denial carries settlement_rejected without new offers. Withholding on an unknown settlement could leave a payer charged for output never sent. If recording completion fails, core emits an error and rethrows instead of presenting the output as paid.
Expected denials use stable error.code, retryable, action, message, and optional rail/requirement detail. Provider outages during verification deny access. Provider timeout/unavailability during settlement or refund records ambiguity; unrelated exceptions propagate. See Errors and each adapter's transport behavior.
Idempotency
| Identifier | Derived from |
|---|---|
| Request ID | New identifier per inbound request |
| Authorization ID | Rail and stable proof ID |
| Charge ID without a key | Authorization and request ID |
| Charge ID with a key | Payer, key, and attempt number |
| Provider operation key | Charge ID and operation |
HTTP uses Idempotency-Key; MCP uses _meta["tollstile/idempotency-key"]. Client keys override protocol-supplied keys. Keys deduplicate within the verified payer identity, which may be per challenge on some rails.
Keyed charges persist a request hash. Route-only quote bindings are strengthened to full request matching for idempotency; custom commitments remain custom. Retries return recorded state: in progress, unknown, already paid (with result reference), or rejected. Released/refunded attempts can proceed to a new charge; a single-use authorization may still require a fresh proof. Subscriber grants create no charge. See Idempotency for all limits.
Policies and requirements
Policies return skip, pay, grant, or reserve. Insufficient balance after a reserve decision releases the attempt and falls through. Grant decisions create no payment record. Reserve decisions use a reusable authorization named policy:<name> and a Balance implementing idempotent reserve, commit, release, and status operations.
Requirements run after payer identity is known and before reserving value. They receive price, quote (possibly null), ledger reader, claims, clock, and abort signal. A temporary inability to verify evidence returns 503 requirement_unavailable, not a permanent refusal. Nonce claims can prevent reuse even when a payment retry has the same key; renew identity evidence as needed.
Rail contract and evidence
Rails implement offer, challenge, verify, settle, refund, release, lookup, and receipt. Verification returns absent, invalid, or valid; an invalid proof may include a stable proof ID so core can recognize an already-used payment from the ledger. Provider calls receive a stable operation key and abort signal.
A rail may retain settlement evidence in authorization data for crash recovery. Optional redact drops single-use evidence after final settlement, failure, or refund, retaining what lookup/refund need. Released proofs keep their evidence for retry. Reusable rail credentials have rail-specific retention rules; see KYAPay.
Reconciliation and events
Reconciliation examines non-terminal charges older than the configured window. It settles completed reservations, releases uncompleted ones, looks up ambiguous settlement/refund outcomes, and retries only through the rail contract. It reports payments it cannot refund or resolve. It never runs the handler or reconstructs a lost result.
Use a shared persistent ledger and a window longer than active handlers and provider visibility delays. Schedule toll.reconcile() yourself; the reconciliation CLI remains planned. See Run reconciliation.
onEvent receives quote.issued, authorization.opened, charge.moved, request.denied, and error. These hooks support application-owned observability; Tollstile adds no telemetry service.
Conformance and verification limits
The conformance kit runs rail contract cases against a fake provider. Ledgers have shared repository suites; adapters and core have their own behavioral tests. Passing fake-provider tests does not verify live provider behavior. The roadmap and each rail page state the remaining validation work.