Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,15 @@ onboarding an existing API is the CLI's main job.
import { createGateway, loadConfig, receipts } from '@devlab.group/agent-commerce';

const config = await loadConfig({ path: 'config.yaml' });
const store = receipts({ path: './receipts.sqlite' });
// Every ReceiptStore must be initialised before anything else touches it -
// for the SQLite one this is where a schema written by a newer version is
// caught, at boot rather than at the first query
await store.init();

const gateway = await createGateway({
config,
store: receipts({ path: './receipts.sqlite' }),
store,
paymentProviders: [],
protocolAdapters: [],
});
Expand Down
15 changes: 12 additions & 3 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,9 +204,18 @@ before it can fail at request time. A complete, validating configuration is in
[examples/acp-checkout](../examples/acp-checkout).

`retentionHours` may not go below 24: a shorter window would let a replayed
`Idempotency-Key` past an expired record and run a checkout twice. The
idempotency database is its own file - it never shares a table with receipts or
the x402 replay defence.
`Idempotency-Key` past an expired record and run a checkout twice. It bounds
*completed* records only. One whose merchant outcome was never learned is kept
until an operator clears it, because to the next retry, deleting it looks
exactly like the operation never having happened. The idempotency database is
its own file - it never shares a table with receipts or the x402 replay
defence.

The gateway forwards an `Idempotency-Key` header to the merchant on every
side-effecting checkout call, derived so that it is stable across retries, a
restart and a token rotation. See
[protocols.md](protocols.md#idempotency) for what the merchant should do with
it.

See [protocols.md](protocols.md#acp) for the wire contract.

Expand Down
2 changes: 2 additions & 0 deletions docs/contract-surface.txt
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ interface BackendHandler {
}

interface BackendRequest {
readonly idempotencyKey?: string;
readonly input: unknown;
readonly requestId: string;
readonly resourceId: string;
Expand All @@ -102,6 +103,7 @@ interface BackendResponse {

interface CanonicalRequest {
readonly authorization?: AuthorizationSubmission;
readonly idempotencyKey?: string;
readonly input: unknown;
readonly metadata?: Readonly<Record<string, unknown>>;
readonly payment?: PaymentSubmission;
Expand Down
1 change: 1 addition & 0 deletions docs/contracts.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ the generated file is right and this table is stale.
- **Additive:** `AuthorizationRecord`; optional `CommerceReceipt.authorization`; `AuthorizationProvider` gains `requirement` and `markUncertain`; `AuthorizationVerification` now extends `AuthorizationRecord`; `CommerceEventType` gains `authorization.verified` and `authorization.rejected`. *Use case:* the execution pipeline enforcing authorization, in the order payment verify -> authorize/reserve -> payment replay reserve -> settle -> consume/release/mark-uncertain. *Why `requirement` on the provider:* the 402 challenge has to name what the retry must also carry, and only the provider knows its own spec version and payload profile. *Why `markUncertain` rather than leaving a reservation alone:* a settlement that was broadcast but never confirmed must not hand the proof back, and "we did nothing" is indistinguishable from a path that forgot to finalize. *Why the receipt stores a record and not the verification:* `reservationId` is a live handle, not an audit fact, and a stored proof would be a spendable secret at rest. *Compatibility:* `CommerceReceipt.authorization` is optional and absent for every resource that requires no authorization; the receipt store adds schema version 2 (`ALTER TABLE receipts ADD COLUMN authorization_json`), so an existing database keeps its rows. `AuthorizationProvider` is not yet implemented by anything shipped, so the two new members break no consumer.
- **Additive (non-frozen surfaces):** `GatewayOptions.authorizationProviders` (optional) and `ReadinessResult.authorizationProviders`; a new `./ap2` subpath exporting `createAp2AuthorizationProvider` / `ap2`, with `jose`, `@sd-jwt/core` and `canonicalize` as optional peers. *Use case:* running AP2 as a wired subsystem. *Why a subpath:* one entry per distinct peer set, named for the peer - a gateway serving no gated resource should install neither a JOSE stack nor an SD-JWT parser, and the main entry and the CLI import the narrow AP2 modules (`constants.ts`, `types.ts`, `descriptor.ts`) so neither pulls a peer. *Readiness:* an authorization provider reporting `fail` blocks `/ready` on the same threshold as a payment provider - a resource that requires a mandate cannot be served without one, and serving its challenge anyway promises what cannot be honoured. Only the fixed vocabulary token `authorization-provider-unreachable` reaches the client. *Compatibility:* both fields are additive and a deployment configuring no authorization behaves exactly as before.
- **Additive (`./ap2` subpath):** `createCheckoutJwt` and `CreateCheckoutJwtOptions`. *Use case:* a merchant has to sign the checkout JWT a Checkout Mandate binds, and the gateway only verifies. *Why it ships:* `input_hash` is an RFC 8785 digest, and a hand-rolled signer reaching for a sorted-key `JSON.stringify` agrees on most inputs and disagrees on floats and non-ASCII keys - producing a mandate refused with a deliberately coarse reason. The helper also refuses a numeric `amount`, the public half of a key pair, a non-P-256 key and a missing field before signing, rather than letting each become that same opaque refusal. *Scope:* signing only. It runs in the merchant's process, never calls the gateway and is never called by it - the mirror of `createPaymentProof`. The Checkout Mandate itself is the buyer's side and nothing here mints one.
- **Additive:** optional `CanonicalRequest.idempotencyKey` and optional `BackendRequest.idempotencyKey`; the HTTP backend executor forwards the latter as the `Idempotency-Key` request header. *Use case:* a merchant that creates an order and then loses the response to a timeout or a 5xx had no way to recognise the retry that followed as the same operation, so one ACP checkout became two orders. *Why not `requestId`:* it is generated per call, so every retry would look like new work; the ACP adapter derives this one from its idempotency scope (deployment, endpoint, caller key), which is identical across a client retry, a reconnect, a gateway restart and a bearer-token rotation. *Why hashed rather than forwarded raw:* the caller's key alone does not name an operation. ACP scopes it per endpoint, so one client may legitimately send the same key to create and to complete, and a merchant keying state on the raw value would read those as one operation; the digest folds deployment, endpoint and key into the single header ACP provides. It does not separate two clients that picked the same key - they share a deployment and an endpoint, so they share a derived key, exactly as they already share a row in the local claim store. *Why a fixed header and not config:* `Idempotency-Key` is the de-facto standard and the same header ACP already mandates inbound, so a merchant speaking ACP needs no second convention; a configurable name can be added if a real backend needs one. A statically configured header of that name is overridden rather than deferred to: a fixed key would make every request after the first look like a retry of the first. *Compatibility:* both fields are optional, every adapter that sets neither behaves exactly as before, and a merchant sees no new header unless one is supplied.
- **Additive (gateway wire surface):** `WellKnownDocument.authorizationProviders`, an `AdapterDescriptor[]` that is empty unless a resource requires authorization. *Use case:* the README promises every adapter's `supportedSpec`, `capabilities` and `unsupported` list is checkable at runtime rather than taken on trust, and AP2 was reportable through `doctor` but absent from the document. *Why a separate field and not `paymentProviders`:* an authorization method is not a payment rail and must never be selectable as one - the same reason `AuthorizationMethodName` is neither a `ProtocolName` nor a `PaymentMethodName`. *Compatibility:* additive; the field is always present, and the dashboard's hand-maintained mirror carries only what it renders, as it already does for `protocols.acp`.
---

Expand Down
68 changes: 54 additions & 14 deletions docs/protocols.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ The adapter contains **no payment logic** and never calls a merchant backend.
| Default mount | `/acp` |
| Services | `checkout` only |
| Authentication | `Authorization: Bearer <token>`, required on every checkout route |
| Idempotency | `Idempotency-Key` required on every POST, durable, retained >= 24h |
| Idempotency | `Idempotency-Key` required on every POST, durable, retained >= 24h, forwarded to the merchant |

The schema is **vendored, not fetched**: the exact released
`schema.agentic_checkout.json` sits in the repository with its upstream commit
Expand Down Expand Up @@ -275,9 +275,12 @@ would be inventing one.

`Idempotency-Key` is mandatory on every ACP POST and is checked before the
request body is even read. A key is scoped by
`(authenticated identity, concrete endpoint path, key)`, where the identity is a
SHA-256 digest of the bearer token - the token itself never reaches the database,
the logs, or a response.
`(deployment, concrete endpoint path, key)`, where the deployment is the
gateway's own public base URL. Deliberately **not** the bearer token or a
digest of it: a claim must survive a credential rotation, and scoping by the
token meant a rotated one found no row, reserved afresh, and re-ran an
operation whose outcome might already be unknown. The token plays no part in
idempotency and never reaches the database, the logs, or a response.

The fingerprint is taken over the **parsed** JSON, so a retry through a
different serializer (different key order, `1.0` where it first sent `1`) is a
Expand All @@ -290,21 +293,58 @@ distinguish requests.
| Same key, same body, still running | `409 idempotency_in_flight` with `Retry-After` |
| Same key, same body, finished | the stored answer, with `Idempotent-Replayed: true`, no merchant call |
| Same key, different body | `422 idempotency_conflict`, no merchant call |
| A `5xx` result | not cached - a clean retry runs again |

Records are kept for at least 24 hours; the configuration floor is the same 24
hours, because a shorter window would let a replayed key past an expired record
and run a checkout twice. Cleanup is lazy, inside the same transaction that
claims a key - there is no background worker.
| Merchant answered, even to refuse (`4xx`) | cached; the refusal is the answer to every retry |
| Merchant reached, outcome unknown (timeout, `5xx`, unreadable reply) | `409 idempotency_unresolved`, no `Retry-After`, the merchant is not called again |
| Failed before the merchant was called | not cached - a clean retry runs |

Completed records are kept for at least 24 hours; the configuration floor is the
same 24 hours, because a shorter window would let a replayed key past an expired
record and run a checkout twice. Cleanup is lazy, inside the same transaction
that claims a key - there is no background worker.

**An unresolved record never expires.** A timeout or a merchant `5xx` is not
evidence that the merchant did nothing: it may have created the order and lost
the response. Freeing the key would hand the next retry a clean slate and place
the second order, so the claim is kept and the caller is told the outcome is
unknown rather than invited to retry. Sweeping such a row on a timer would
re-create that same bug, so retention only ever deletes a completed one.
Clearing an unresolved record is an operator's decision, taken against the
merchant's own records.

### What the merchant must implement

The gateway forwards an **`Idempotency-Key` request header** on every call it
makes for a side-effecting ACP operation. It is not the caller's key but a
SHA-256 digest over `(deployment, endpoint, caller key)`, so it is

- **identical** across a client retry, a network-level retry, a gateway restart
and a credential rotation, which is what makes it usable as the name of an
operation;
- **different** for the same key used on two endpoints, and for two gateways
fronting the same backend;
- **opaque** - it carries back neither the caller's key nor anything about the
gateway.

A merchant should key its own record of a side-effecting operation on that
value: if a request arrives under a key it has already completed, return the
original result rather than performing the operation again. It also needs some
way to look an operation up by that key, because that is what an operator uses
to resolve an unresolved record.

**If the merchant does not implement it**, everything above still holds for
retries the gateway sees - the claim is durable and concurrency-safe, and no
ambiguous failure ever frees one. What weakens is the case the gateway cannot
see: a request that reached the merchant twice by some path outside it, such as
a proxy retry or a duplicate delivery, which only the merchant can collapse.

**The limit, stated plainly.** A merchant side effect over HTTP and a local
SQLite commit are not one transaction. Ordinary retries and concurrency are
protected durably, but if the process dies after the merchant completed an order
and before the answer was stored, that key stays claimed and every retry is
answered `409` until it expires - deliberately, because re-running a completion
whose remote state is unknown risks charging a buyer twice. This is not
exactly-once semantics across a remote system, and it is not claimed to be:
merchant-side idempotency on destructive operations is still recommended.
refused - deliberately, because re-running a completion whose remote state is
unknown risks charging a buyer twice. This is not exactly-once semantics across
a remote system, and it is not claimed to be: merchant-side idempotency on
destructive operations is still recommended.

### Errors

Expand Down
14 changes: 11 additions & 3 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,8 +199,11 @@ buffer or parse anything.

Discovery at `/.well-known/acp.json` stays public and carries no configuration:
no bearer token, no backend URL, no mapped resource ids, no idempotency database
path. Only the digest of the token is ever persisted - the idempotency store
scopes keys by `SHA-256("acp-auth:" + token)`, never by the token itself.
path. The token is not persisted in any form - the idempotency store scopes
keys by the gateway's public base URL, and the token plays no part in it. It
used to scope by `SHA-256("acp-auth:" + token)`, which kept the secret out of
the file but let a rotation free every outstanding claim. A credential must not
be able to do that, so the scope moved off it entirely.

`Signature` and `Timestamp` verification are **not implemented**, are not
advertised, and a `Signature` header is never accepted in place of the bearer
Expand Down Expand Up @@ -341,6 +344,7 @@ rejection outcomes assert that balances did not move.
| an unknown key nested under an `additionalProperties` schema | rejected by the closed schema | same |
| a hostile or unbounded facilitator rejection string | clamped before it reaches buyer, event or ledger | `tests/unit/payments-x402` |
| facilitator timeout | `PAYMENT_PROVIDER_UNAVAILABLE`, settlement treated as *uncertain* | `tests/unit/payments-x402` |
| a settle() throw the provider cannot classify | `PAYMENT_PROVIDER_UNAVAILABLE`, *uncertain* - never a rejection | `tests/unit/payments-x402` |
| **facilitator 401 / 5xx** | `PAYMENT_PROVIDER_UNAVAILABLE`, never charged to the buyer | `tests/integration/adversarial-payment.test.ts` |
| **malformed facilitator response** | refused; never read as a verdict | same |
| backend timeout | `BACKEND_TIMEOUT` | `tests/unit/core/execution` |
Expand All @@ -356,6 +360,10 @@ rejection outcomes assert that balances did not move.
| **an ACP request naming an unsupported API version** | 400 naming `supported_versions`; never mapped to the pinned one | same |
| **an ACP POST with no or an over-long `Idempotency-Key`** | 400 before the body is read | same |
| **an ACP key replayed while the first request is in flight** | 409; the merchant is called exactly once | `tests/conformance/acp/idempotency.test.ts` |
| **an ACP operation the merchant acted on before timing out** | 409 `idempotency_unresolved`; one order, never two | same |
| **an ACP retry after a merchant 5xx** | the claim is held, not freed; the merchant is not called again | same, `tests/conformance/acp/errors.test.ts` |
| **an unresolved ACP record outliving its retention window** | kept; only a completed record expires | `tests/unit/protocols-acp/idempotency.test.ts` |
| **an ACP claim outliving a bearer-token rotation** | kept; the scope is the deployment, never the credential | same |
| **an ACP key reused with a different body** | 422; the merchant is called exactly once | same |
| **an ACP completion retried after a 5xx** | not cached; the clean retry runs | same |
| **a merchant answering an ACP route with a non-ACP document** | refused as `processing_error`; its body never forwarded | `tests/conformance/acp/errors.test.ts` |
Expand All @@ -375,7 +383,7 @@ rejection outcomes assert that balances did not move.
| **a mandate replayed under selective disclosure** (one mandate, many presentation strings) | refused - the replay key is the issuer-signed token, not the presentation | `tests/unit/authorization-ap2` |
| **the AP2 replay store unreachable** | `AUTHORIZATION_PROVIDER_UNAVAILABLE`, retryable, never the buyer's fault | `tests/integration/ap2-runtime.test.ts` |
| **a payment rejected after a mandate verified** | the reservation is released; a corrected proof reuses the mandate | `tests/integration/ap2-x402-conformance.test.ts` |
| **a settlement broadcast but never confirmed** | the mandate is *not* handed back; marked uncertain for an operator | same |
| **a settlement whose outcome never came back** (timeout, reset, proxy 502, with or without a transaction hash) | the mandate is *not* handed back; marked uncertain for an operator | same, `tests/unit/core/execution` |
| **a free resource configured to require a mandate** | refused at config load, and again on the execution path | `tests/unit/config/ap2.test.ts`, `tests/unit/core/execution` |
| **an oversized `Agent-Authorization` header** | `AUTHORIZATION_INVALID` before any decode; nothing echoed back | `tests/integration/authorization-carrier.test.ts` |

Expand Down
Loading
Loading