add path targeting to Request Constraints - #744
Conversation
Request Constraints described what to validate but did not define how a nested response occurrence corresponded to request data. Leaving that mapping to each domain made array positions fragile and could not express cross-shape bindings, such as an available payment instrument constraining a submitted instrument. Bind each value to request data with an optional, outer-only `path`. An explicit path is a complete RFC 9535 JSONPath query evaluated against the next request; when omitted, the path is derived from the Normalized Path of the structured response object containing the constraint. This preserves concise ambient authoring while supporting stable-identity queries and multi-object selection. Validate every selected object against the complete closed Constraint Expression in conjunction with the resolved request schema. Zero matches have no effect, all matches must pass, and overlapping expressions compose without precedence. Businesses must ensure potentially overlapping expressions are jointly satisfiable. Keep Business evaluation authoritative while making Platform preflight optional and advisory. A Platform may evaluate a supported subset, but must process each chosen value completely and skip malformed, unsupported, non-object, or resource-limited values rather than partially interpreting them. Existing operation outcomes and messages continue to govern runtime failures. Each authoritative resource response supplies the advertised constraints for the next request to that resource; the following response replaces that set, omission clears it, and sets are never merged by path. Require explicit payment instrument paths to bridge response and request shapes by matching handler and instrument identity.
raginpirate
left a comment
There was a problem hiding this comment.
Thanks for all the deep thinking here Ilya. Approving, with a note from our discussion in the deep dive earlier.
Another solution is to put checkout.ucp.request_constraints on the response root as a single property, which carries an array of constraints and their paths in one place. As you mentioned, an implementer could even choose to do this.
In a vacuum, I think thats the simple solution, as it avoids having to walk the tree to assemble all the fragments you'll evaluate to restrict your json schema. However, I'll concede that it is very nice to keep the constraints authored in a semantically-relevant place in the schema; something I care deeply about for our payment requests 😄
Lets land this thing!
gsmith85
left a comment
There was a problem hiding this comment.
Thanks for driving this through, @igrigorik! This looks great and aligns with what we discussed in the deep dive.
Adding RFC 9535 path targeting gives us the precision needed for array reordering and payment shape bridging while keeping the common ambient case clean and flat. LGTM! 🚀
Co-authored-by: Daniel Wyckoff <daniel.wyckoff@shopify.com>
jamesandersen
left a comment
There was a problem hiding this comment.
I was a bit late to the party here ... but agree this is a good catch and addition @igrigorik - thanks!
* add response-carried request constraints
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`.
* register request constraints in the `ucp` namespace
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.
* simplify request constraints validation & preflight
The TC discussion sharpened the intended split: Request Constraints narrow
the resolved request schema, Business enforcement is authoritative, and
Platform preflight is an optional optimization rather than a second
conformance boundary.
Define effective request validation as the conjunction of the resolved request
schema and emitted constraints. Require Businesses to emit conforming
constraints and enforce every rule, while allowing Platforms to evaluate any
supported subset after validating the member's closed shape. A successful
preflight can therefore avoid doomed requests without claiming that every
emitted constraint has passed.
Keep the wire language focused on required, properties, enum, and const by
removing display annotations and $comment. Clarify that constraint objects are
embedded JSON Schema rather than ambient UCP objects, and make each operation
responsible for scope and lifecycle.
* extract reusable constraint expression grammar
Request Constraints currently combines two independent concerns: a closed
predicate language and a binding that applies that language to subsequent
request data. Keeping both in request_constraints.json makes the grammar
appear request-specific and prevents other contracts from referencing it
without inheriting that name and semantic scope.
Extract the Object and Value Constraint grammar into the direction-agnostic
constraint_expression.json schema. Keep request_constraints.json as the
binding to subsequent UCP request data. Each containing UCP property owns
its request and response applicability; the shared grammar has no direction
of its own.
A domain reuses the grammar by defining a target model and a constraint
binding for that target. For example, payment could define:
PaymentAcceptanceFacts {
brand,
is_po_box,
credential_type
}
Payment Acceptance Constraints would reference Constraint Expression and
state that emitted expressions are evaluated against PaymentAcceptanceFacts:
{
"$id": "https://example.com/payment_acceptance_constraints.json",
"title": "Payment Acceptance Constraints",
"$ref":
"https://ucp.dev/schemas/common/types/constraint_expression.json"
}
A payment handler could then expose that binding through its own carrier:
"acceptance_constraints": {
"$ref": "payment_acceptance_constraints.json",
"ucp_request": "omit"
}
An emitted value could constrain the derived facts using only the shared
operations:
{
"required": ["brand"],
"properties": {
"brand": {
"enum": ["visa", "mastercard"]
},
"is_po_box": {
"const": false
}
}
}
The payment contract remains responsible for deriving
PaymentAcceptanceFacts and defining authority and lifecycle. The binding
schema validates that the emitted expression uses the shared grammar, and
the emitted expression is then evaluated against the derived facts.
Constraint Expression admits two recursive positions. Object Constraints
use required and properties to constrain field presence and nested objects.
Value Constraints use enum and const to constrain JSON values. Target field
names remain domain-defined while the executable operation vocabulary stays
closed.
This boundary gives Platforms one coherent evaluator contract. N domains
can bind the grammar to their own target models, and M Platforms can
implement the grammar once, producing an N + M integration model. Allowing
each handler to define executable keywords would instead require bespoke
semantics for every Platform x handler pair and trend toward N x M
integrations.
The refactor does not add payment acceptance facts or another domain
binding. It establishes the reusable grammar those contracts can adopt
later. The existing Request Constraints wire language, response-only
applicability, validation behavior, and Business enforcement remain
unchanged.
* inline object constraint at expression root
Model Constraint Expression directly as its root Object Constraint instead
of delegating through the repository's only top-level local reference.
Preserve recursive Object and Value Constraint semantics while remaining
compatible with the existing schema reference renderer.
* clarify Request Constraints authoring and outcomes
Replace overloaded grammar and host terminology with actionable guidance on
where expression syntax and request-binding changes belong.
Distinguish stable, proactive preflight from runtime outcomes reported through
messages. Advise Businesses not to advertise volatile constraints unless they
can continue enforcing them, and describe quantity locks as exact sale-basis
steps rather than lot semantics.
* add path targeting to Request Constraints (#744)
* add path targeting to Request Constraints
Request Constraints described what to validate but did not define how a nested
response occurrence corresponded to request data. Leaving that mapping to each
domain made array positions fragile and could not express cross-shape bindings,
such as an available payment instrument constraining a submitted instrument.
Bind each value to request data with an optional, outer-only `path`. An explicit
path is a complete RFC 9535 JSONPath query evaluated against the next request;
when omitted, the path is derived from the Normalized Path of the structured
response object containing the constraint. This preserves concise ambient
authoring while supporting stable-identity queries and multi-object selection.
Validate every selected object against the complete closed Constraint Expression
in conjunction with the resolved request schema. Zero matches have no effect,
all matches must pass, and overlapping expressions compose without precedence.
Businesses must ensure potentially overlapping expressions are jointly
satisfiable.
Keep Business evaluation authoritative while making Platform preflight optional
and advisory. A Platform may evaluate a supported subset, but must process each
chosen value completely and skip malformed, unsupported, non-object, or
resource-limited values rather than partially interpreting them. Existing
operation outcomes and messages continue to govern runtime failures.
Each authoritative resource response supplies the advertised constraints for
the next request to that resource; the following response replaces that set,
omission clears it, and sets are never merged by path. Require explicit payment
instrument paths to bridge response and request shapes by matching handler and
instrument identity.
* Update source/schemas/common/types/request_constraints.json
Co-authored-by: Daniel Wyckoff <daniel.wyckoff@shopify.com>
* fix malformed Request Constraints schema
* clarify Request Constraints path resolution
* restore explicit Request Constraints root members
The path-targeting change referenced Constraint Expression's `required`
property directly, and a follow-up applied the same factoring to `properties`.
Although both references are valid Draft 2020-12, the documentation renderer
turns both anonymous targets into nonexistent anchors. The `properties` target
also contains relative nested references whose source-resource scope the
released bundler does not preserve.
Spell out `required` and `properties` at the Request Constraints root while
continuing to reference the shared grammar for nested Object and Value
Constraints. This preserves the wire contract and keeps the PR compatible with
today's toolchain.
The property-level references can replace these duplicated root shapes after
resource-aware fragment bundling and anonymous-target documentation rendering
are both supported and released.
#655 defines a reusable closed Constraint Expression grammar, but the response location alone does not always identify the request data to validate. For example, a constraint attached to a response Line Item could mean the item at the same array position, the item with the same identity, or some other request object. The ambiguity becomes unavoidable when arrays reorder or when response and request shapes differ, such as an available payment instrument constraining a submitted instrument.
This PR adds an optional, outer-only
pathto Request Constraints. An explicitpathis a complete RFC 9535 JSONPath query evaluated against the next logical UCP request to the same resource. When omitted, the effective path is derived from the RFC 9535 Normalized Path of the structured response object whoseucpmember contains the constraint.The omission behavior preserves concise ambient authoring. A constraint on the response root derives
$; a constraint on a nested object derives that object's positional path. That positional correspondence is deterministic but does not imply stable identity. Authors use an explicit query when a rule must survive reordering, select multiple objects, or bridge different response and request shapes.A path may select zero, one, or many objects. Zero matches have no effect, while every selected object must satisfy the complete Constraint Expression. Overlapping constraints compose conjunctively without precedence or override, and the Business must ensure potentially overlapping expressions are jointly satisfiable. These checks narrow the resolved request schema; they cannot make an otherwise invalid request valid.
Binding shape
The chosen wire shape keeps
pathbeside the root members of the expression:{ "path": "$['line_items'][?@['id'] == 'line_123']", "properties": { "quantity": {"const": 100} } }One considered alternative was separating path from constraint expression:
{ "path": "$['line_items'][?@['id'] == 'line_123']", "expression": { "properties": { "quantity": {"const": 100} } } }The benefit of the latter is that keeps expression strict subset of JSONSchema. The cost is permanent wire noise in the common case. Most constraints use ambient placement and omit
path, so even a simple local requirement would need an otherwise meaningless wrapper:{ "expression": { "required": ["billing_address"] } }The selected shape optimizes for the authoring and wire surface rather than schema-factoring purity.
constraint_expression.jsonremains direction-neutral and contains nopath;request_constraints.jsonis the closed binding that admitspathonly at its root and re-exposes the root Object Constraint members. Recursive constraints continue through the pure expression schema, so nestedpathmembers remain invalid. The trade-off is that future changes to the root Object Constraint must also be reflected inrequest_constraints.json.This preserves the core invariant that one binding carries one schema. It avoids introducing a list of independently targeted rules, repeated navigation, target precedence, or another orchestration model alongside JSON Schema.