Skip to content

feat: add response-carried request constraints - #655

Open
igrigorik wants to merge 3 commits into
mainfrom
feat/request-constraints
Open

feat: add response-carried request constraints#655
igrigorik wants to merge 3 commits into
mainfrom
feat/request-constraints

Conversation

@igrigorik

@igrigorik igrigorik commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Depends on #657.

Static request schemas describe the inputs a Business generally accepts, but not narrower requirements for a particular transaction. Without response-time constraints, a Platform learns that a negotiated quantity is fixed or an otherwise optional field is required only after submission fails—or through domain-specific constraint formats.

PR #657 establishes the ambient ucp protocol namespace and central member registry. This PR adds request_constraints to that vocabulary instead of inventing a parallel structural namespace.

  • Registers response-only ucp.request_constraints in ucp.json#/$defs/members.
  • Defines a closed JSON Schema Draft 2020-12 value grammar using required, properties, enum, and const.
  • Lets a Business communicate and authoritatively enforce transaction-specific input constraints.
  • A Platform MAY use the member to form or validate a request, or safely ignore it and rely on Business validation.
  • Applies the fragment alongside the resolved request schema; both validations must pass, equivalent for validity to allOf without schema merging.
  • Uses the containing ucp scope to identify the constrained parent object; adopting contracts define correspondence and lifecycle when existing operation semantics do not.

Examples

A Cart Line Item fixed at quantity 100:

{
  "id": "line_123",
  "quantity": 100,
  "ucp": {
    "request_constraints": {
      "properties": {
        "quantity": {"const": 100}
      }
    }
  }
}

A submitted card instrument that requires a billing address for this transaction:

{
  "type": "card",
  "ucp": {
    "request_constraints": {
      "required": ["billing_address"]
    }
  }
}

Boundaries

This PR does not define payment availability, custom assertion keywords, schema merging, or new outcomes and error codes. Payment-specific correspondence and availability remain downstream.

@igrigorik
igrigorik requested a review from raginpirate July 31, 2026 04:37
@igrigorik igrigorik self-assigned this Jul 31, 2026
UCP registries are reverse-DNS keyed JSON maps, and JSON object member
order is not a protocol contract: RFC 8785 (JCS) canonicalization, used
by UCP signing, sorts object member names. Businesses have no reliable
way to declare preferred traversal order for registry keys, e.g. payment
handler presentation order (#170, design discussion in #525).

The model: the member name `ucp` is reserved at every object scope as
the protocol namespace. The top-level envelope is the root manifestation
of that reservation, not a special wrapper. The namespace is ambient:
any object scope MAY carry a `ucp` member, domain schemas never declare
it (it is document grammar, like the reservation itself), and its
contents are defined exclusively by the vocabulary registered in
ucp.json#/$defs/members. Consumers process the members they recognize
and ignore the rest; a member is admitted to the vocabulary only if it
is safe to ignore, so no member can be load-bearing for correctness.
One exception: an object closed with additionalProperties:false must
declare the optional `ucp` property explicitly. Future members register
once and work at every scope immediately -- request constraints (#655)
can rebase onto this vocabulary.

The first member, map_order, declares key-traversal order for sibling
map-valued fields, carried in an array because JCS preserves array
element order. Partial lists are valid, unlisted keys remain valid and
follow, and the list is not an allowlist. At the root envelope it orders
the registries beside it:

  "ucp": {
    "payment_handlers": {
      "com.google.pay": [ ... ],
      "dev.shopify.shop_pay": [ ... ]
    },
    "map_order": {
      "payment_handlers": ["dev.shopify.shop_pay", "com.google.pay"]
    }
  }

At any deeper scope the same member rides the ambient `ucp` member, with
no schema change to the host object -- e.g. ordering an identity
provider registry inside a capability config:

  "config": {
    "providers": {
      "app.example.login": [ ... ],
      "com.google": [ ... ]
    },
    "ucp": {
      "map_order": { "providers": ["app.example.login", "com.google"] }
    }
  }

Refs #525, #170
   Static request schemas describe the inputs a Business accepts generally, but
   cannot express narrower requirements for specific transaction. Platforms
   therefore cannot know before submission that a negotiated quantity is fixed
   or that an otherwise optional field is required.

   Add response-only `$requestConstraints` as a bounded Draft 2020-12 fragment
   that a Business emits and enforces against the corresponding later request
   representation. A Platform may use the fragment when forming or validating
   input, or ignore it and rely on the Business's existing validation errors.

   Keep the structural member ambient rather than adding an ordinary property to
   every carrier schema. Each adopting contract defines correspondence and
   lifecycle, while the shared type closes the vocabulary to `required`,
   `properties`, `enum`, and `const`, with optional display text and `$comment`.

   Examples demonstrate a Cart Line Item fixed at quantity 100, preservation of
   the company-scoped `ACME-X7Q9-L2M4` discount code, and a submitted card
   instrument that requires `billing_address`.
   Static request schemas cannot express transaction-specific narrowing, forcing
   Platforms to discover negotiated requirements only after submission fails.

   Register response-only `ucp.request_constraints` in the central protocol
   vocabulary and define a closed Draft 2020-12 fragment for object presence and
   value constraints. Attach constraints through the containing `ucp` scope so
   host schemas do not need carrier-specific declarations.

   Keep Platform processing optional and Business validation authoritative,
   preserving the namespace requirement that registered members remain safe to
   ignore.
@igrigorik
igrigorik force-pushed the feat/request-constraints branch from 7521e4d to 3105ad5 Compare July 31, 2026 18:56
@igrigorik
igrigorik marked this pull request as ready for review July 31, 2026 18:58
@damaz91 damaz91 added the status:needs-triage Signal that the PR is ready for human triage label Jul 31, 2026
@igrigorik igrigorik added the TC review Ready for TC review label Jul 31, 2026
@igrigorik
igrigorik requested a review from amithanda July 31, 2026 19:05
@damaz91 damaz91 added status:under-review gov:needs-tc-review and removed status:needs-triage Signal that the PR is ready for human triage labels Aug 3, 2026
@jamesandersen
jamesandersen self-requested a review August 5, 2026 02:53
@jamesandersen

jamesandersen commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Consolidating the concerns raised across this constraints arc (#288#424#580#626) and where each lands under request_constraints, assuming this + #657 supersedes the #424/#626 availability-constraint refactor rather than coexisting with it.

Legend: ✅ resolved / moot · ⚠️ partial or deliberately out of scope · ❌ not addressed

# Concern Raised on By Status under #655
C1 Convey per-transaction constraints so a platform learns them before completing, not via submit-and-fail (incl. "is CVV required?") #288 @jamesandersen (upfront framing revisited on #424 by @igrigorik) ✅ Core purpose. Response-carried → create-then-complete, no failure loop. Depends on a payment correspondence + lifecycle contract, still downstream.
C2 Field-name/typo validation (billing_addr vs billing_address) #288 (reraised #626) @alexpark20 (later @raginpirate) ⚠️ Out of scope by design — authoring-time concern, not wire-validation. Smaller blast radius since the business both authors and enforces.
C3 Brittle named booleans/enums; constraints should mirror the target schema #288 @gsmith85, @kmcduffie ✅ Superseded by the structural JSON-Schema-subset grammar.
C4 Polymorphic credential discrimination — a cvc requirement must not apply to a token branch #288 / #424 @raginpirate, @gsmith85 ⚠️ Partial. No type dispatch, so "cvv only for the card credential, not the token credential" within one instrument isn't expressible in the grammar. Cleanest fix is structural, not conditional: reviving the distinct network-token credential from #424 (@raginpirate) makes discrimination positional and request_constraints needs no if/then.
C5 Enforcement gap — the type discriminator didn't drive validation; malformed configs passed #424 @TateLyman, @jamesandersen, @igrigorik ✅ Moot by design — the business is the authoritative enforcer; correctness no longer rides the platform's schema validator.
C6 constraints not truly generic / extension-friendly (allOf only narrows) #424 @igrigorik ✅ Sidestepped — grammar is closed + core-owned. Arbitrary target fields (incl. extension-defined) can still be constrained; only new predicate kinds can't be added.
C7 Credential inception — nested cvc on a raw card behind a provider token #424 @raginpirate ⚠️ Structural nesting is expressible; type-conditional nesting isn't. Non-blocking IMO; @raginpirate WDYT?.
C8 Expressiveness / locality — assertions that aren't presence-or-value: accepted-value menus (brands), numeric ranges (B2B min/max qty), relational predicates #580 / #626 @raginpirate (ack @igrigorik) ✅ Division of labor. request_constraints handles transaction-time presence/value narrowing; advertised/specialized/derived constraints keep living in the instrument schema's existing constraints member (e.g. card_payment_instrument.constraints.brands). Less uniform, but no regression.
C9 options over-strict typing (additionalProperties = string-arrays; credentials a hardcoded exception) #626 @raginpirate ✅ Dissolved — there's no options axis.
C10 properties-keyword collision (the reason #424/#626 used direct keys) #626 @igrigorik ✅ Resolved — applied as a standalone fragment, so literal properties carries no merge collision.

Net: this arc has genuinely come a long way. The debates that took the most back-and-forth — genericity (C6), where enforcement lives (C5), the properties collision (C10) — are effectively settled here, and the original #288 goal of surfacing constraints early enough to skip a doomed round-trip (C1) is finally within reach. What's left feels like a couple of deliberate, eyes-open choices rather than open problems: reviving the distinct network-token credential from #424 so discrimination is structural instead of conditional (C4), and being explicit that advertised/specialized constraints stay in the domain schema's constraints member while request_constraints owns transaction-time narrowing (C8). We're close.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants