Specification
About the specDraft 0.5v0.3 bindingAmbiguitiesSpec changelogRCTP v0.2 binding (Starter profile of Draft 0.4)
Capability version: 2026-11-draft4 · Release: v0.2.0-draft.4 · UCP release: v2026-08-25 (UCP Ask at commit
f2626c2)
Draft 0.4 says what an enforcement point must decide. This document fixes the wire details v0.2
needs so that the Go gateway, the TypeScript and Python SDKs, the sandbox and the conformance runner interoperate. It
replaces v0.1-binding.md, which stays as the record of v0.1. Where the draft is silent, the choice
and its reason are logged in AMBIGUITIES.md (rows B-n), always the more restrictive reading.
Key words follow BCP 14 as in the draft. Section numbers in brackets refer to Draft 0.4. Schemas are in
schemas/; every body below names its schema.
1. Keys and UCP profiles [4.1]
- Agents, operators and gateways sign with ECDSA P-256 (
ES256;ecdsa-p256-sha256in RFC 9421), the UCP baseline. ECDSA signatures use rawr||sencoding (RFC 9421 §3.3.1), as UCP requires. - Every party publishes a UCP profile (schema
ucp-profile) athttps://<host>/.well-known/ucp, with its public keys inkeys[]. - RCTP requires each key's
kidto be its RFC 7638 JWK SHA-256 thumbprint, base64url without padding (B-1). The RFC 9421keyidis thatkid. A key whosekidis not its thumbprint is unusable. - An operator whose agents call an enforcement point MUST list
dev.rctp.operatorinucp.capabilitieswithconfig=legal_entity,jurisdiction,incident_contact,incident_sla_hours, and MAY addcertifications,data_residency,key_custody[4.1(2)]. An operator that wants webhooks listsdev.rctp.dealwithconfig.webhook_url(section 9). - Reference gateway policy: operators are onboarded by profile URL in gateway configuration. The gateway caches a profile for at most 5 minutes.
- Private keys never appear in any profile, record, log or example.
2. Request signing [4.1]
Every agent request to an enforcement point is signed per UCP Message Signatures (REST binding, RFC 9421), with the RCTP additions marked RCTP.
| Item | Value |
|---|---|
| Identity header | UCP-Agent: profile="<operator profile URL>" (RFC 8941 Dictionary) |
| Signature label | sig1 |
keyid |
kid of a key in the operator profile's keys[] (its thumbprint) |
created, expires |
RCTP: REQUIRED; expires - created ≤ 300 s; created within ±60 s of the verifier's clock (B-2) |
Idempotency-Key |
REQUIRED on every POST; at least 128 bits of entropy |
| Covered components | "@method" "@authority" "@path" "ucp-agent"; "@query" when the target has a query; "idempotency-key" on POST; "content-digest" and "content-type" when there is a body; RCTP: "authorization" when that header is present |
| Body digest | Content-Digest per RFC 9530 with sha-256, over the raw body bytes |
The verifier checks, in this order, and refuses with 401 signature_invalid unless a row says otherwise:
Signature,Signature-Input(labelsig1) andUCP-Agentare present.UCP-Agentnames an onboarded profile that resolves and validates → elseoperator_unknown.keyidis akidin that profile'skeys[]→ elseoperator_unknown.- The signature verifies; every required component is covered;
createdandexpireshold;Content-Digestmatches the body.
Idempotency (UCP replay protection). The gateway stores each POST's Idempotency-Key with the request digest and
the response for 48 hours, per operator profile. A repeat with the same key and digest gets the stored response without
re-executing. A repeat with the same key and a different digest gets 409 {"error":"idempotency_conflict"}. If the
store is unavailable, the gateway answers 503 and executes nothing.
3. Agent Grant [5]
3.1 Discovery
The marketplace's gateway serves OAuth protected-resource metadata (RFC 9728) at
/.well-known/oauth-protected-resource naming its authorization server. The authorization server serves RFC 8414
metadata at /.well-known/oauth-authorization-server, as UCP Identity Linking requires (issuer matched byte for byte).
RCTP scopes are declared, as every UCP scope is, in the config.scopes of the marketplace's
dev.ucp.common.identity_linking capability entry. Each scope's policy object carries default_tier and
allowed_tiers [5.2(2), 8.4]:
"dev.rctp.deal:offer_accept": { "default_tier": "ask", "allowed_tiers": ["ask", "auto"] }
3.2 Issuance [5.2]
- Authorization code grant with PKCE (
S256). The authorization request carries RFC 9396authorization_detailswith one object of typedev.rctp.agent_grant:{"type", "agent_id", "jkt", "tiers": {"<scope>": "<tier>"}}, wherejktis the thumbprint of the agent key that will sign calls. The requested scopes are the keys oftiers. - The consent page shows the agent, its operator, each verb with its tier, and the grant expiry. It asks for no prices.
The seller may lower a tier within the scope's
allowed_tiers; the grant records what the seller confirmed. - The token response carries the access token (opaque),
token_typeBearer,expires_in≤ 900 s, a refresh token, and the grantedauthorization_details. Refresh tokens rotate on every use; reuse of a rotated refresh token revokes the grant. - A grant expires at most 90 days after issue [5.2(4)]; refresh never extends it past that.
3.3 Use on every call [5.3]
- The seller agent sends
Authorization: Bearer <access token>. The request signature MUST coverauthorization. - The gateway introspects the token on every seller-side call (RFC 7662; the reference gateway authenticates with
client_secret_basic), never caching the answer (INV-7). The response (schemagrant-introspection) carriesactive,scope,sub(the seller's pairwise pseudonym),exp,cnf.jkt,grant_statusand thedev.rctp.agent_grantauthorization details. - Checks and refusals, in order:
| Check | Refusal |
|---|---|
Authorization present and covered by the signature |
401 grant_invalid |
| Token known to the authorization server | 401 grant_invalid |
cnf.jkt equals the request keyid |
401 grant_invalid |
grant_status is not revoked |
403 grant_revoked |
grant_status is not expired (grant past exp) |
403 grant_expired |
active (the access token itself is current; else refresh and retry) |
401 grant_invalid |
| The verb's scope is granted | 403 action_forbidden |
- RFC 7662 lets an inactive token answer only
active: false. RCTP's authorization server addsgrant_statusfor tokens it issued, to the authenticated gateway only, so revoked and expired grants get their own reasons (B-3).
3.4 Revocation [5.4]
The seller revokes on a marketplace screen; operators revoke their own tokens (RFC 7009). Because every call is introspected, a revoked or expired grant fails the next signed call (INV-7, V6).
4. Listing Terms [6]
4.1 Term sets [6.2]
A term set (schema term-set, registry term-sets)
lists its terms with type, visibility, whether required, and constraints from the closed set min, max, gt_term,
lt_term, lte_list_price, min_ratio_of_list_price. The gateway advertises the term sets it enforces in
config.term_sets of its dev.rctp.listing entry.
4.2 Setting terms on the hosted page [6.1]
- The seller agent calls
POST /listings(schemalisting-create-request, which names theterm_set). It sends no private terms. The answer is always 202ask. - The seller opens
hosted_url.GET /approve/{id}/requestreturns the approval request and the term-set definition, from which the page builds the form. Nothing is pre-filled [6.1(6)]. - The page posts the seller's values to
POST /approve/{id}/terms(schematerms-submission). This route is for the approval page only: it is not agent-signed and is refused for any request carryingUCP-Agent. The gateway checks the constraints (422terms_invalidbefore the seller can approve, [6.2]), draws a 128-bit salt and returnsterms_commitmentand the WebAuthn options whose challenge binds it. - The seller approves with their passkey (
POST /approvals/{id}/response, carryingterms_commitment). The gateway stores exactly the terms behind that commitment [6.1(3)]. - The agent learns the outcome from
GET /approvals/{id}or the webhook: the listing, itsterm_setandterms_commitment, never the values [6.1(4)].
terms_commitment = "sha-256:" + hex(SHA-256(JCS({"salt": <base64url salt>, "terms": <terms>}))), where terms is
the listing-terms terms object and JCS is RFC 8785 [6.4(2)].
POST /listings/{id}/terms (listing.set_terms, empty body) starts the same flow for an existing listing.
4.3 Privacy [6.4]
Private terms (floor, counter) never appear in listing data, offers, decisions, approval request bodies, webhooks,
partner-readable logs or deal records. Public terms (available_until, fulfillment_options, payment_terms,
release_condition, deliver_within_days, …) appear in listing data under public_terms.
5. Offers and Deal Terms [7]
5.1 Objects
An offer and a counter are both offer objects, with by = buyer or seller.
Each carries deal_terms (schema deal-terms: price, fulfillment,
payment), negotiation_id, optional supersedes, and expires_at.
| Operation | Caller | Verb | Effect |
|---|---|---|---|
POST /offers |
buyer | offer.create |
New buyer offer. With supersedes naming a counter or the buyer's own open offer, that one becomes superseded |
POST /offers/{id}/counter |
seller | offer.counter |
New seller offer (by = seller, supersedes = {id}); {id} becomes countered |
POST /offers/{id}/counter |
buyer | — | Refused 400; a buyer answers a counter with POST /offers and supersedes [7.3(1)] |
POST /offers/{id}/accept |
seller | offer.accept |
Only on a buyer offer; reserves the listing (5.3) |
POST /offers/{id}/accept |
buyer | — | 403 action_forbidden [8.3] |
POST /offers/{id}/decline |
either party | — | Always allowed for the parties |
5.2 Identity, revision and expiry [7.2, 7.3]
negotiation_id(neg_…) is assigned by the gateway on a buyer agent's first offer on a listing and is the same for every later offer and counter between that buyer agent (key thumbprint) and that listing. It is random, never derived from the buyer.supersedesmust name an offer in the same negotiation that isopenorcountered; otherwise 422terms_invalid.expires_at: the creator MAY send it; otherwise the gateway setsnow + offer_expiry.default_seconds. The gateway caps it atnow + offer_expiry.max_secondsand at the listing'savailable_until. Both numbers are inconfigof the gateway'sdev.rctp.dealentry (reference defaults: 48 hours and 7 days).- At
expires_atan offer not accepted becomesexpired; any later action on it is 409offer_expired. Anaskon an offer expires no later than the offer.
5.3 Acceptance, reservation and checkout [7.4]
- A completed accept (directly under
auto, or after an approvedask) makes the offeracceptedand gives itcheckout: a compact JWS signed by a key in the gateway profile (typrctp-checkout+jwt), claims percheckout:iss,offer_id,negotiation_id,listing_id,listing_hash,deal_terms,buyer_jkt,nonce,iat,exp(reference default 30 minutes, never pastavailable_untilplus 30 minutes). - The listing becomes
reserved. While it is, every otheroffer.accepton it gets 409listing_reserved; buyer offers are still accepted asopen. Accepting the same offer again gets 409offer_replayed. - The buyer redeems with
POST /deals(schemadeal-create-request), signed by the key whose thumbprint isbuyer_jkt. First use → 201deal, and the listing becomessold. A later use of the same nonce → 409offer_replayed; afterexp→ 409offer_expired; a different key → 401signature_invalid. - At checkout
expwithout redemption the reservation ends: the listing returns toactiveand the offer toexpired. - Accepted Deal Terms are fixed:
listing.set_termsandlisting.repriceon a reserved listing change future offers only. If the seller withdraws a reserved listing on the marketplace, redemption fails with 403not_in_scopeand the withdrawal is written to the deal record.
5.4 Buyer authority [7.5]
AP2 Checkout Mandate verification (7.5(1), vector V25) is not part of v0.2.0-draft.4: AP2 v0.2 mandates are SD-JWT delegation chains, and no pinned Go library verifies them, so implementing it now would mean custom cryptography (INV-8). Tracked in #59. Until then a buyer agent's authority is its operator's onboarding.
6. Decisions [8]
RCTP-Decision is an RFC 9651 Item: a Token with parameters (schema decision).
| Decision | HTTP | Header | Body |
|---|---|---|---|
| auto | 200 (201 for creates) | auto |
the resource |
| ask | 202 | ask;approval_id="apr_…";expires_in=900;on_timeout=deny |
{"approval_request": {…}} |
| forbid | 401, 403, 409, 422 | forbid;action="offer.accept";reason=below_floor;counter=340.0 |
error |
| budget exhausted | 429 | forbid;reason=offer_budget_exhausted;retry_after=3600 |
error |
counteris an sf-decimal and is present only when the listing'scounterterm is set (the term set requires it to be above the floor).- Protocol errors that are not decisions (malformed JSON, schema violations, unknown routes, idempotency conflicts) use
400/404/409/503 with
{"error": "invalid_request" | "not_found" | "unsupported_version" | "idempotency_conflict" | "unavailable", "message": "…"}and noRCTP-Decisionheader. - Check order is exactly Draft 0.4 section 8.1: signature and operator → grant (seller side) → listing → buyer requirements (buyer side) → terms, Deal Terms and budget → tier. Every decision, refusals included, appends a deal record entry.
- Tiers come from the grant's
tiersfor the verb's scope, within the scope'sallowed_tiers; otherwise the scope'sdefault_tier. Fixedforbidverbs (deal.sign_contract,account.update) have no scope and are always 403action_forbidden(INV-3). - Offer budget [8.5]: below-floor offers are counted per buyer agent (key thumbprint) per listing, and per buyer
operator per listing, over
window_hours. Once either count reaches its limit, every offer from that agent or operator on that listing gets 429 until the window frees a slot. The limits are inconfig.offer_budgetof the gateway'sdev.rctp.dealentry (reference: 3 per agent, 10 per operator, 24 hours). - Request bodies are closed schemas: a body field such as
floorortieris a 400, never an override (INV-5).
6.1 Which verb each operation evaluates
| Operation | Caller | Verb | Floor | Tier |
|---|---|---|---|---|
POST /listings |
seller | listing.create |
— | always ask |
POST /listings/{id}/terms |
seller | listing.set_terms |
— | always ask |
POST /listings/{id}/price |
seller | listing.reprice |
new price ≥ floor | grant |
POST /listings/{id}/answers |
seller | listing.answer_buyer |
— | grant |
POST /offers |
buyer | offer.create |
yes, plus budget | none: delivered at 201 |
POST /offers/{id}/counter |
seller | offer.counter |
counter price ≥ floor | grant |
POST /offers/{id}/accept |
seller | offer.accept |
offer price ≥ floor, whatever the tier (V1) | grant |
POST /deals/{id}/commit-to-ship |
seller | deal.commit_to_ship |
— | grant |
POST /deals/{id}/contract |
seller | deal.sign_contract |
— | always 403 |
POST /account |
seller | account.update |
— | always 403 |
An offer on a listing without approved terms has no seller agent: it is delivered to the marketplace as an ordinary
offer (decision auto), and a seller agent acting on that listing is refused not_in_scope [6.5].
7. Approvals at level hosted_embedded [9]
As in v0.1 binding section 6, with these changes:
- The 202 body's
approval_request.authorization_details[0](typedev.rctp.approval) addsterm_setand, once the seller has filled the form,terms_commitmentforlisting.createandlisting.set_terms. For those two verbs the challenge is computed afterPOST /approve/{id}/terms, over the details that includeterms_commitment; the approval request in the 202 body carries no challenge yet (B-4). displaynever contains private terms; the terms form appears only on the hosted page [9.2(2)].approval_responseaddsterms_commitment(required for listing approvals).- On
approve, the gateway re-checks the grant (introspection), the floor and the reservation, then executes the pending action exactly as it was asked. - No valid response by
expires_atis a decline (INV-2); a pending offer becomesexpired. - Seller passkey enrolment stays a reference-gateway feature (v0.1 binding 6.5), with the enrolment ticket issued by the marketplace authorization server.
8. Deal record and events [10]
8.1 Writing events
POST /deals/{id}/events (schema deal-event-post-request) carries
{"event": "<compact JWS>"}, typ rctp-deal-event+jwt, signed by the same key as the request, payload
deal-event.
| Event | Kind | Who may write it | Precondition (else 409 release_condition_unmet) |
|---|---|---|---|
funds_held |
RCTP | the holder of funds (an operator profile configured as such) | none |
shipped, delivered, returned_to_sender |
UCP fulfillment event | carrier (first_hand) or marketplace (relayed) |
funds_held |
accepted |
RCTP | the buyer agent (buyer_jkt) |
funds_held; neither accepted nor rejected yet |
rejected (reason: not_as_described, not_received) |
RCTP | the buyer agent | as accepted; not_received only after deliver_within_days since funds_held |
return |
UCP adjustment | the seller agent, carrier or Custodian | rejected with not_as_described |
released |
RCTP | the holder of funds | funds_held and the release condition (default accepted) |
refund |
UCP adjustment | the holder of funds | rejected + not_as_described + return; or rejected + not_received |
Where the listing's term set sets required_observation for an event, the event counts only with that observation
[10.3(4)]. A relayed event names reported_by and source_event_id [10.4(2)].
8.2 The record
- The enforcement point writes an entry (
attestation-entry) for every decision, including refusals, and for every accepted event, into a hash chain per subject (negotiation, deal or listing).prev_hash=sha-256:<hex>of SHA-256 over the JCS of the previous entry; the first is 64 zeros. Gateway entries carry its JWS over the entry body. - Entries carry
terms_commitmentwhere terms apply, never a floor [10.4(1)], and no names, addresses or phone numbers; locations are city-level; evidence is digests [10.4(5)]. GET /deals/{id}/eventsreturns the deal's entries to its parties.
9. Webhooks
UCP signed webhooks (Standard Webhooks headers, RFC 9421 signature), to the config.webhook_url of the dev.rctp.deal
entry in the receiving operator's profile.
| Header | Value |
|---|---|
Webhook-Id |
the event's event_id |
Webhook-Timestamp |
Unix seconds of the event |
UCP-Agent |
profile="<gateway profile URL>" |
Content-Type, Content-Digest |
application/json; RFC 9530 sha-256 |
Signature-Input, Signature |
label sig1, components "@method" "@authority" "@path" "content-digest" "content-type", with keyid, created |
Body: webhook-event {"event_id", "type", "created_at", "data"}, types
approval.decided, offer.updated, deal.event. Receivers verify the signature against the gateway profile's
keys[], check Content-Digest, reject a Webhook-Timestamp more than 300 s old, and drop repeated Webhook-Ids. The
SDKs do this (verifyWebhook / verify_webhook).
10. Discovery and versions
GET /.well-known/ucp returns the gateway profile: UCP version 2026-08-25; capabilities
dev.ucp.common.identity_linking (RCTP scopes with tiers), dev.rctp.listing (config.term_sets) and
dev.rctp.deal (config.offer_expiry, config.offer_budget), each at version 2026-11-draft4; and keys[].
Clients MAY send RCTP-Version: 2026-11-draft4 (an sf-string); an unsupported version is 400 unsupported_version. The
reference gateway v0.2 serves 2026-11-draft4 only; a deployment that must keep 2026-10-draft3 runs v0.1 beside it
for at most 90 days [4.5(3)].
10a. Open values in v0.2
Draft 0.4 4.5(2) makes term names and event types open values. v0.2 narrows both, so that closed schemas can carry them:
- Term names: only the eleven names of Draft 0.4 6.2 are accepted. A term set that needs another term waits for a registry addition.
- Deal events: an
eventis an RCTP checkpoint, one of the UCP values in section 8.1, or a reverse-domain extension such asexample.carrier:picked_up_sealed. Extension events are recorded as informative and never change a money rule. - Fulfillment and payment options in Listing Terms are lists of
{method_type, option_ids}and payment term IDs, so the gateway can check an offer'sselected_option_idandselected_term_idagainst them.
11. Personal data [15, INV-9]
No RCTP object carries names, addresses, phone numbers or identity numbers. The seller is a pairwise pseudonym (sub
of the grant); a buyer is its agent key thumbprint. Schemas for every object are closed and have no field where
personal data could go.
12. Endpoints (reference gateway)
Binding routes are part of this binding (the conformance suite uses them); reference routes belong to the reference gateway only.
| Method and path | Caller | Decides | Status |
|---|---|---|---|
POST /listings |
seller | listing.create |
spec 12 |
GET /listings/{id} |
any operator | — | reference |
POST /listings/{id}/terms |
seller | listing.set_terms |
spec 12 |
POST /listings/{id}/price |
seller | listing.reprice |
binding |
GET /listings/{id}/questions, POST /listings/{id}/questions |
seller, buyer | — | reference |
POST /listings/{id}/answers |
seller | listing.answer_buyer |
binding |
POST /offers |
buyer | offer.create |
spec 12 |
GET /offers?listing_id= |
seller | — | reference |
GET /offers/{id} |
either party | — | spec 12 |
POST /offers/{id}/counter, /accept, /decline |
parties | offer.counter, offer.accept |
spec 12 |
GET /approvals/{id} |
operator that asked | — | spec 12 |
POST /approvals/{id}/response |
approval page only | — | spec 12 |
GET /approve/{id}, /approve/{id}/request; POST /approve/{id}/terms, /approve/{id}/options |
the seller's browser | terms_invalid |
binding |
POST /deals |
buyer (checkout key) | single use | spec 12 |
GET /deals/{id} |
parties | — | reference |
POST /deals/{id}/events, GET /deals/{id}/events |
parties, holder of funds, carrier | money rules | spec 12 |
POST /deals/{id}/commit-to-ship |
seller | deal.commit_to_ship |
binding |
POST /deals/{id}/contract |
seller | deal.sign_contract → always 403 |
binding (INV-3) |
POST /account |
seller | account.update → always 403 |
binding (INV-3) |
GET /enroll, POST /enroll/options, POST /enroll/complete |
the seller's browser | — | reference |
GET /.well-known/ucp, GET /.well-known/oauth-protected-resource |
anyone | — | binding |
Source: spec/v0.2-binding.md