# The UCP face of this seller: what is the standard's, what is this profile's choice Status (2026-09-25): implemented in the seller (`src/ucp.mjs`, `src/ucp-catalog.mjs`, `src/httpsig.mjs`), on the same search and purchase functions as the A2A, MCP and REST interfaces; the buyer's side is §7, the catalog §8 (since 2026-09-25). Written from the Universal Commerce Protocol as published at ucp.dev, release **2026-08-25** (Overview, Message Signatures, Shopping Checkout with its REST and MCP bindings, the AP2 Mandates extension, the Payment Handler Specification Guide, and Shopping Catalog with its Search, Lookup, REST and MCP pages), read on 2026-09-23, 2026-09-24 and 2026-09-25, and from `contracts/mandate-ap2-profile.md`, which the mandate side of this file builds on. The rule of this file is the rule of that one: say what the text of the standard fixes, say where it leaves a choice and which one this seller made, and say what is a vendor field under this seller's own namespace, so that nobody reads a convention of ours as a rule of the protocol. ## 1. What a stranger finds, in order | Step | Where | Standard | |---|---|---| | The profile | `https://travel.agenthaven.dev/.well-known/ucp` | Overview, Profile Structure: `ucp.version`, `ucp.services`, `ucp.capabilities`, `ucp.payment_handlers`, top-level `keys[]` (a JWK Set) | | The services | `dev.ucp.shopping` over `rest` at `/ucp/v1` and over `mcp` at `/ucp/mcp` | Overview, Services; Checkout REST and MCP bindings | | The capabilities | `dev.ucp.shopping.checkout`, and `dev.ucp.common.payment.ap2_mandate` extending it; `dev.ucp.shopping.catalog.search` and `dev.ucp.shopping.catalog.lookup` (§8) | Checkout; AP2 Mandates extension; Catalog | | The payment handler | `dev.agenthaven.payment.test_rail`, spec and schema served by the zone `agenthaven.dev` | Overview, Authority Binding (the `schema` origin MUST be the namespace's authority); Payment Handler Specification Guide | | The key | `keys[0]`: the instance key, ES256, `kid` = its RFC 7638 thumbprint, the same key the SVID vouches for | Message Signatures, Key Format; Overview, Key Discovery | The catalog is the search. `search_flights`, open, on the existing MCP endpoint and on the UCP MCP endpoint, returns quotes, and since 2026-09-25 the Catalog capability returns the same quotes as products (§8); **the `item.id` of a UCP checkout is a quote id** (`q_` and a UUID), valid ten minutes, which is the id of the catalog's variant. ## 2. The session One quote per checkout (`line_items` of length one, `quantity` 1; more is a warning and a clamp, another item id is `item_unavailable`). The states are the standard's six; which ones this seller uses, and when: | State | When | |---|---| | `ready_for_complete` | the AP2 extension is in the intersection and the quote is live | | `requires_escalation` | the platform did not negotiate the AP2 extension: the seller has no way to learn that a person authorized the purchase, so it hands off (`continue_url`, a page that says exactly that) and never charges. Message `authorization_required`, severity `requires_buyer_review` | | `completed` | the purchase ran: `order.id` is the seller's receipt id (`r_` and a UUID), `order.permalink_url` the public order document | | `canceled` | canceled by the platform, or the quote expired (message `expired`) | | `incomplete`, `complete_in_progress` | not used: nothing is collected from the buyer, and completion is synchronous | `totals` are `subtotal` and `total`, equal, in minor units of the seller's currency. `links` carries `terms_of_service` (the landing page). `policies` carries `dev.agenthaven.policy.proof_of_concept`, which says that the fare is real, the rail is a test rail and no ticket is issued. `expires_at` is the quote's expiry. `payment.instruments` echoes the instrument the platform submitted, without its credential. ## 3. Identity and signatures - **Every request names the platform's profile**: `UCP-Agent: profile="https://…"` on REST, `meta["ucp-agent"].profile` on MCP. Without it: `invalid_profile_url` (400). The profile is fetched as the Overview's Fetching section asks: https only (a loopback exception exists for the local conformance run and is off in production), no redirects, five-second timeout, a 256 KiB bound, special-use addresses refused, a 60 s TTL floor, a cache of 64 profiles, a budget of 60 fetches a minute for unknown profiles (`profile_unreachable`, 503, Retry-After when spent), one forced refresh per TTL on an unknown `kid`. - **Negotiation** is the Overview's Intersection Algorithm on the two capabilities this seller offers, at the one version it speaks; a platform on another release is `version_unsupported` (422); an empty intersection is a resourceless `capabilities_incompatible` answer. - **Requests MAY be signed; completion MUST be.** HTTP Message Signatures (RFC 9421) under a key the platform profile's `keys[]` publishes, named by `keyid`, covering `@method`, `@authority`, `@path`, `@query` when present, `content-digest` and `content-type` when a body is present, and `ucp-agent`, `idempotency-key` and `signature-agent` when present. ES256 is the baseline; ES384 and Ed25519 are verified too; another key type is `algorithm_unsupported` and never invalidates the set. An unsigned create is served, and so is every call on a session opened unsigned: opening a session costs nothing and commits nobody, and the official conformance suite opens them that way. An unsigned completion is `signature_missing` (401): the mandate is bound to the key that signs the request, and there is none. - **A session opened signed stays signed** (since 2026-09-25). Once a create's signature verified, the session's get, update, complete and cancel, over REST and MCP, and its create answered again under the same `Idempotency-Key`, need a signature under a key the owning platform's profile publishes, and are refused as a completion is: `signature_missing` (401) unsigned, and a signature that fails by its own code (`signature_invalid`, `key_not_found`, `digest_mismatch`), as on any call. UCP has platforms sign every request (SHOULD) and treats one whose signature does not verify as unsigned, which leaves what to accept to the business; this is this seller's answer. - **Responses are signed**, all of them, under the instance key: `@status`, `content-digest`, `content-type`, `keyid` = the instance thumbprint, the key in the profile's `keys[]`. - **Idempotency**: `Idempotency-Key` (REST) or `meta["idempotency-key"]` (MCP), scoped to the platform and the operation, kept 48 h; the same key with the same body answers the cached bytes, with another body `idempotency_key_reused` (409). Over MCP the body compared is the tool arguments in JCS form, not the JSON-RPC envelope, whose id changes on every retry (UCP #664). Completion and cancel over MCP require the key; over REST it is optional, as the binding says. ## 4. The mandate, in UCP form The AP2 Mandates extension fixes the placements: the closed Checkout Mandate in `ap2.checkout_mandate`, the closed Payment Mandate as the payment instrument's `credential.token`. Both are the Delegate SD-JWT chains of `mandate-ap2-profile.md` and are verified by the same code, with three differences the binding brings: | | Envelope path | UCP path | |---|---|---| | Hop `nonce` | the quote id | the checkout session id | | Item the open mandate may name | the offer id | the quote id or the offer id | | The closed checkout (check 7) | `checkout_jwt` equal to the quote JWS, byte for byte | this seller's own signature over the session's terms, in either shape below | | Who signed the request (check 9) | the envelope key, an attested SPIFFE identity | the RFC 9421 key from the platform profile; the mandate's `act.sub` must be that profile URL or an identity under the same domain (or its zone) | **The two shapes of the closed checkout.** AP2 v0.2 says the closed Checkout Mandate carries `checkout_jwt`, a merchant-signed JWT of the Checkout; the UCP extension says the mandate "MUST contain the full checkout response including `ap2.merchant_authorization`". The seller's `ap2.merchant_authorization` is a detached JWS (RFC 7515 Appendix F) whose payload is the JCS form of the checkout minus `ap2`, so the same bytes reattached are a `checkout_jwt`: `
..`. This seller accepts both: 1. `checkout_jwt` = that reattached JWS (AP2 v0.2 shape); 2. `checkout` = the checkout object with its `ap2.merchant_authorization` (UCP shape). In both, the signature must be this seller's (its `kid` the instance key) and the terms (id, currency, line items, totals) must equal the session's; `checkout_hash` and the payment's `transaction_id` are the base64url SHA-256 of the reattached JWS. Refusals are in band, on the checkout, with the AP2 error receipt under `ap2.checkout_receipt_jwt`: `merchant_authorization_invalid`, `mandate_scope_mismatch`, and every code of the profile's checks 4 to 10 (`key_binding_mismatch`, `mandate_invalid`, `mandate_consumed`, …), with the check number in the message and `path` `$.ap2.checkout_mandate`. Since 2026-09-26 (D-136) the message also carries `.detail`, the cause where the code has several, and `.contract`, the address of the cause or the code in `reason-codes.html`; the UCP message schema admits fields beyond its own. A protocol error (401, 403, 404, 409, 429) carries `detail` and `contract` beside `code` and `content`. One purchase closed this way, with what a verifier derives from it, is `vectors/positive/ucp-closed-chains.json`; the seller, the buyer and the verifier are each tested against it (README). **Revocation** works as on the envelope path, both halves: a mandate its authority reports revoked is refused at check 10 (`mandate_revoked`), and a completion signed with a key the platform's domain lists in the `revocations` of `https:///.well-known/agent-keys.json` (`schemas/key-revocation.schema.json`) at check 3 (`identity_revoked`), each with the same `request.refused` entry and no payment. **A guest** (`guest-buyer.md` §7), where this seller takes guests, completes with the same two chains, their roots issued by the platform itself: a root whose open content names the platform's origin as `iss` is verified under the key its `kid` names in the profile's `keys[]` (check 4), and no authority's key set is read. The pair is then held to the guest tier: the ceiling (check 6), a life of minutes (check 8), the day's count, and check 10, which reads the profile again, never the DNS, for each key that signed a root. The entries say `mandate_tier: guest`, `mandate_issuer` the origin and `mandate_format: ap2-v0.2`. Only the chains travel here: a `mandate+jwt` in either place is refused like any presentation that is not a closed chain (`mandate_invalid`, check 4), a guest's or a listed authority's. **Receipts.** UCP has no field for the AP2 receipts (a gap, to be filed). This seller puts the Checkout Receipt and the Payment Receipt under `ap2.checkout_receipt_jwt` and `ap2.payment_receipt_jwt` on the completion response, and its own `receipt_jws`, with the ledger entries, on the order permalink (`/orders/`, public like the ledger). **The ledger** gets the same two entries as any purchase (`payment_intent.created`, `checkout.completed`), and the same refusals, with `buyer` = the platform profile URL, `buyer_key_source: ucp-profile`, `ucp_checkout_id` and `transport` (`ucp-rest` or `ucp-mcp`), under one correlation id per session: the one the session's quote was created under, which the quote's signed payload names (`correlation_source: quote`), so the quotes and the checkout of a purchase share it; a quote that names none gives the session one the seller mints. Until the evening of 2026-09-24 the seller always minted one. ## 5. Vendor fields, under `dev.agenthaven` The standard has no field for them yet; they are named under this zone's namespace so nobody mistakes them for the protocol's: - in the AP2 capability's `config`: `dev.agenthaven.mandate_issuers` (the authorities this seller accepts, by issuer URL), `dev.agenthaven.mandate_forms`, and `dev.agenthaven.guest` (the guest tier, when on); - the payment handler `dev.agenthaven.payment.test_rail` and the policy type `dev.agenthaven.policy.proof_of_concept`; - in the catalog (§8): the search input `dev.agenthaven.flight`, the correlation id `dev.agenthaven.correlation_id` in a search and in its answer, and `dev.agenthaven.quote` on each variant. ## 6. What the official conformance suite proves here, and what it cannot `scripts/ucp-conformance.sh` runs `Universal-Commerce-Protocol/conformance` against a local merchant. What passes: discovery (the profile validated by the official SDK's `BusinessSchema` model), negotiation, opening a session, idempotent creation with a 409 on a changed body (with `CURRENCY=USD`, because the suite's conflict case changes the currency to EUR). What the suite cannot exercise here: its session helper assumes a `fulfillment` block (it is written for a flower shop that ships), so every lifecycle test stops in the harness before asserting anything about this seller; its completion tests pay with mock tokens for a mock handler, which this seller refuses because it charges only under an AP2 mandate; and its version-negotiation test sends `UCP-Agent: profile="..."; version=…`, the header of the previous release, which the current one refuses as `invalid_profile_url`. The suite has no catalog tests: run on 2026-09-25 (its checkout of 2026-09-08, `CURRENCY=USD`), it answers 8 passed, 28 failed and 3 skipped with the catalog declared as it did without, discovery included (the profile with the two catalog capabilities validates under `BusinessSchema`); the catalog is held to the release's schemas instead (§8). ## 7. The platform side: this POC's buyer (2026-09-24) The buyer of the sibling repository (`agent-buyer`, Python, `ucp.py`, `ucp_client.py`, `httpsig.py`) buys from this seller over UCP since 2026-09-24. What it does is written here because it is the other half of every choice above, and because the next platform to read this profile should find one worked example. - **Its profile**, at `https://buyer.onbehalf.dev/.well-known/ucp`, is a UCP profile and a JWK Set in one document: `ucp.version` 2026-08-25, `services` and `payment_handlers` present and empty (a platform serves nothing and pays with the mandate), `capabilities` the checkout, the AP2 Mandates extension over it and, since 2026-09-25, the catalog search, `keys[]` the buyer's stable key named by its RFC 7638 thumbprint (the same key its `agent-keys.json` attestation vouches for and the mandate's `cnf` names; after a handover the retired key stays listed for its hour), and one vendor field under its own reversed host, `dev.onbehalf.buyer.mandate_issuers`, the authorities it obtains mandates from, so a seller can tell ahead whether the two trust lists meet. - **Discovery**, before anything is asked: this profile is fetched from the host the DNS-AID record led to and the DANE pin covers, and five checks join the buyer's `counterparty.verified` entry after the record, the card and the instance: the release (`version_unsupported` otherwise), the shopping service's `rest` and `mcp` endpoints on that host (`endpoint_out_of_zone`, `transport_unsupported`), the intersection with the checkout and the AP2 extension in it (`capabilities_incompatible`), the instance key the SVID vouches for published in `keys[]` (`ucp_key_mismatch`), a payment handler whose `config.credential_type` is `ap2_mandate` and that takes a card (`payment_handler_unsupported`), and, when the profile lists `*.mandate_issuers` under the seller's namespace, one of the buyer's authorities among them (`issuer_not_accepted`). A profile that does not fit refuses the purchase like a record that does not validate, and nobody is asked to approve a mandate. - **The catalog** is `search_catalog` on the seller's MCP endpoint when the intersection has `dev.ucp.shopping.catalog.search` (the buyer's profile declares it since 2026-09-25), the route in `dev.agenthaven.flight` and the buyer's correlation id in `dev.agenthaven.correlation_id`, the namespace read as the seller's zone reversed; each variant's `dev.agenthaven.quote` is verified exactly as an A2A option's quote is, and a variant without a verifiable signed quote is refused (`quote_invalid`). Without the capability, `search_flights`, the correlation id in its arguments, as before. - **Signatures.** Every request after the profile is signed in the default UCP shape (no `Signature-Agent`, no tag): `@method`, `@authority`, `@path`, `ucp-agent`, `idempotency-key` when sent, `content-digest` and `content-type` when there is a body; `created`, `expires` (five minutes) and `keyid` the thumbprint; ES256 in fixed-width r||s. Every UCP response is verified under a key of this profile's `keys[]` before it is read, and an unsigned one is refused (`signature_missing`), whatever it says; the profile itself and the order permalink are read unsigned, the latter carrying the seller's signed receipt. The Python signer was checked against this seller's Node verifier and the Node response signer against the Python verifier, both ways, on 2026-09-24. - **The session.** One `POST /checkout-sessions` per quote (`line_items` of one, the quote id, `quantity` 1, the currency), kept for a retry. The answer is held to the quote (id, price, currency, total) and to `ready_for_complete`; `requires_escalation` is refused as `capabilities_incompatible` (the buyer completes only under a mandate). The `ap2.merchant_authorization` is reattached over the JCS checkout minus `ap2` and verified under the instance key (`merchant_authorization_invalid`, `signer_unknown`) before the checkout is trusted, as the extension asks. - **The mandate.** The same two AP2 chains, closed by the same code, with the session id as the hops' `nonce` and the reattached merchant authorization as `checkout_jwt` (shape 1 of §4); `checkout_hash` and the payment's `transaction_id` are its SHA-256. The completion carries the closed Payment Mandate as the handler's `credential.token` (`type` `ap2_mandate`, the handler and the instrument type read from the profile, never assumed) and the closed Checkout Mandate under `ap2.checkout_mandate`. A retry after `payment_unknown` completes the same session with the chains re-closed and a fresh `Idempotency-Key`: the body is new, so the key must be; what keeps the rail from charging twice is the mandate's `jti`, from which this seller derives its rail key, as on every path. - **The receipt** comes from the order permalink the signed completion names (same host), verified as the A2A answer is: the receipt under the instance key citing the quote hash, the intent and the ledger entry; the two AP2 receipts by reference. - **What the ledgers say.** This seller's entries for a session carry `ucp_checkout_id`, `transport` (`ucp-rest`), `buyer` the platform profile URL, `buyer_key_source` `ucp-profile`, and the correlation id of the session's quote (§4); the buyer's `receipt.sealed`, `request.refused` and `payment.ambiguous` carry `transport`, `ucp_checkout_id` and `seller_correlation_id`. The join a verifier makes is on the mandate's `jti` and the quote hash, as before, and its attribution join also finds a session the seller filed under an id of its own, by the session id both sides sign (agent-verifier V-052: the first UCP purchase, 2026-09-24, sits under two ids). The buyer's export for a verifier says `nonce` (the session id) and `checkout_jwt_is: ucp_merchant_authorization`. - **The verifier** (`agent-verifier`, since the same day) reads that binding: the SDK walks the chains with the session as the expected nonce; the disclosed checkout is verified under the seller's instance key and held to the session, the quote and the amount the seller's entries name; and the open mandate's merchant and line-item constraints are evaluated against the seller's own entries, because the reference SDK's evaluator reads a Checkout of UCP 2026-04-08 (with a `merchant` member the 2026-08-25 checkout does not have) and matches the line item by the id the authority named, the offer, where the UCP checkout names the quote. That gap between AP2 v0.2's Checkout type and UCP 2026-08-25 is a candidate upstream note. ## 8. The catalog (2026-09-25) The Catalog capability of the release (Shopping Catalog, its Search and Lookup pages and their REST and MCP bindings), declared beside the checkout as `dev.ucp.shopping.catalog.search` and `dev.ucp.shopping.catalog.lookup`, version 2026-08-25, spec and schema on ucp.dev. It is grammar over the search this seller has: nothing is found, priced or signed that `search_flights` does not. | Operation | REST, under `/ucp/v1` | MCP, at `/ucp/mcp` | Capability | |---|---|---|---| | Search | `POST /catalog/search` | `search_catalog` | `dev.ucp.shopping.catalog.search` | | Batch lookup | `POST /catalog/lookup` | `lookup_catalog` | `dev.ucp.shopping.catalog.lookup` | | Get product | `POST /catalog/product` | `get_product` | `dev.ucp.shopping.catalog.lookup` | The body is the request the schemas define; over MCP it travels as `arguments.catalog`, beside `arguments.meta` with `ucp-agent`, as the binding has it. **A product is one quote.** Each offer the search signs a quote for is one Product with exactly one Variant, and the variant's `id` is the quote id (`q_` and a UUID), so it goes into a checkout's `line_items[].item.id` unchanged (Relationship to Checkout). The specification asks for a global id and its examples prefix products `prod_`: the product's id is `prod_` and the quote's UUID, unique as the quote is. The title is the one the quote signs, which is the checkout's line item title; the variant's title says which option it is (`Cheapest, 1 stop`); the description is the itinerary in words, whether the fare is a fixture, and until when the quote holds. `price` and `price_range` are Price objects (minor units, ISO 4217); `availability` is `{available: true}`, as the specification's examples give it; `tags` is the option's basis (`cheapest`, `fastest`). A product lives as long as its quote: lookup and get_product resolve a variant id or a product id while it does, and not after. **The search input.** The standard's are `query` and `filters`, and it lets an extension define another. This seller's is `dev.agenthaven.flight`, `{origin, destination, departure_date, adults}`, which is `search_flights`' own arguments, handed to it and judged as they are. A `query` is read for two IATA codes, uppercase, one date, YYYY-MM-DD, and "N adults", and for nothing else. When neither names a route nothing is searched, and the answer is the specification's message shape: `ucp.status` `error`, `products: []` and one error message, `request_invalid`, `recoverable`, saying what is missing. A route the search refuses (an origin that is not IATA) comes back the same way, with the `request.refused` entry `search_flights` writes. No extension capability is declared for `dev.agenthaven.flight`: it is a vendor field of the request, described by the `search_catalog` tool's input schema and by this section. **The correlation id.** UCP defines none (the REST `Request-Id` names one request, not a purchase). A search takes an optional `dev.agenthaven.correlation_id`, a lowercase UUID, and mints one when it is absent, exactly as `search_flights` does with `correlation_id`: the quotes are written under it (`quote.created`, `correlation_source` `buyer` or `seller`), each quote signs it, the answer echoes it as `dev.agenthaven.correlation_id`, and a session opened on one of the variants inherits it (§4). **The same search.** Anonymous unless a bearer is presented, as on the other interfaces: it spends the anonymous budget, and a spent budget is the protocol's 429 with `Retry-After` (`-32000` and `retry_after` over MCP); the entries are those of `search_flights`, `operation` included. A request signature is optional, as on the checkout operations that do not need one: one that is there must verify, and it changes nothing else. **The rest of the grammar.** The capability of the operation must be in the intersection, or the answer is `capabilities_incompatible` in the operation's own shape (search and lookup keep `products: []`, get_product answers the Error Response). An answer names only its operation's capability (Response Capability Selection); a checkout answer names only the checkout and its extension. A request the schemas do not admit is `invalid_request`, and a lookup of more than 20 ids `request_too_large`, both 400 over REST and `-32602` over MCP. Lookup deduplicates the ids, returns each product once with its variant's `inputs` (`exact` for the variant id, `featured` for the product id) and an info `not_found` whose content is the id; get_product answers `not_found`, `unrecoverable`, in the Error Response. `filters.price` is applied in the seller's currency when `context.currency` names it or nothing (to what the search found: every offer is still quoted and written), and in another currency is not applied and says so (info `price_filter_ignored`); a product carries no category, so `filters.categories` matches nothing and nothing is searched. A page holds 10; a smaller `limit` gets a cursor that names the quotes left, read back from the quote store without searching again. **Vendor fields**, under `dev.agenthaven`: `dev.agenthaven.flight` and `dev.agenthaven.correlation_id` in the search request, `dev.agenthaven.correlation_id` in its answer, and on each variant `dev.agenthaven.quote`, `{jws, hash, expires_at}`: the quote as it was signed (`quote+jwt`, under the key `keys[]` publishes), its hash (SHA-256, lowercase hex, over the payload octets) and its expiry, for a platform that verifies signatures. **Held to the official schemas.** `schemas/ucp-2026-08-25/` keeps the release's schemas as served on 2026-09-25, unchanged, `$id` kept; every catalog answer in `test/ucp-catalog.test.mjs` is validated against `search_response`, `lookup_response`, `get_product_response` or the Error Response. The official conformance suite has no catalog tests (§6).