feat: factor available-instrument constraints into distinct axes#580
feat: factor available-instrument constraints into distinct axes#580igrigorik wants to merge 2 commits into
Conversation
Constraints let a business declare, per checkout, which otherwise-optional
fields it requires (e.g. CVC, billing postal code) and which values it
accepts. This cannot live in the base schema: UCP schemas are deliberately
open and multi-tenant -- one shared schema (e.g. dev.shopify.card) serves
thousands of merchants, each with different, per-context requirements. A
design-time schema fixes one contract; the requirement here is a per-party,
per-interaction *narrowing* of that contract, so it must travel as data.
And a narrowing is, conceptually, a schema -- "these fields required, these
values allowed." Rather than invent a parallel vocabulary (required_fields,
allowed_values, ...) that reimplements JSON Schema under worse names, a
constraint is modeled as a *restricted, open* subset of JSON Schema itself,
carried as data and consumed by the platform to learn requirements.
The contract (constraint.json):
- A constraint is a sparse OVERLAY on an already-typed base schema. It
carries no `type` -- the base defines shape; the overlay only narrows.
- The vocabulary is bounded to `required` (which properties must be
present), `properties` (per-property nested constraints, recursive),
and `enum` (allowed values for a property).
- It is OPEN: it narrows named properties and never forbids unknown ones.
`additionalProperties:false` / `unevaluatedProperties` are disallowed so
extensions can still add fields, and `enum` here is per-party runtime
data -- not a base-schema enum -- so it never closes the protocol
vocabulary.
- Excluded on purpose: `oneOf`, `if`/`then`, `not`, `pattern`, and
cross-file `$ref`. These are the interop-tax / DoS / extensibility-
closing surface that every open multi-party format bounds or
externalizes (DIF Presentation Exchange, FHIR profiles, HAL-FORMS). The
restriction IS the contract: a consumer only ever has to understand
presence + nested presence + allowed values.
Discriminated families (accepted credential types) are a typed list --
type_constraint.json is a constraint plus a `type` discriminator -- resolved
by data lookup: the consumer applies the entry matching the submitted
credential's type, and unknown types are extension branches that pass
through. Keeping per-type requirements in a list (not a schema `oneOf`) is
what lets the vocabulary above stay free of the excluded keywords.
Changes:
- constraint.json: required_fields -> required/properties/enum; recursion
via `$ref:"#"` (cross-file self-ref breaks the bundler; same-document
`#` does not).
- type_constraint.json: inline {type + constraint}, dropping the nested
`constraints` wrapper so credential entries are flat {type, required}.
- available_payment_instrument.json: {type, constraints:{constraint +
credentials list}}.
- card_payment_instrument.json: the card branch only pins type:"card";
brands and credential entries ride on the open base.
- token_credential.json: drop the now-unused $defs/constraint, whose
if/then dispatch is replaced by the credentials data list.
Notes:
- `brands` stays a capability key (accepted networks), not a
`properties.brand.enum` value constraint: a card's network is derived
from the credential rather than a settable field, so it is capability
advertisement, not field-value validation.
- Requirements a constraint cannot pre-state (dynamic/conditional, e.g.
risk-based step-up) remain discoverable via the reactive message loop
(checkout.status + message_error.path). The declared constraint is an
authoritative floor, not an exhaustive spec.
An `available_instruments[]` entry declares what an acceptable instrument must
satisfy. Folding every kind of rule into one `constraints` bag is hard to
reason about and — because some of those keys are not JSON Schema keywords —
quietly wrong when the object is run through a validator (a `credentials` rule,
for instance, is silently ignored). Separate the rules into three sibling
axes, each with the mechanism it actually wants:
{
"type": "card",
"constraints": { // fields of the instrument ITSELF
"required": ["billing_address"],
"properties": {
"billing_address": {
"required": ["postal_code", "address_country"],
"properties": { "address_country": { "enum": ["US", "CA"] } }
}
}
},
"accepts": { // menus of DERIVED/selected options
"brand": ["visa", "mastercard"]
},
"credentials": [ // per-credential-type requirements
{ "type": "pan", "constraints": { "required": ["cvc"] } },
{ "type": "network_token", "constraints": { "required": ["cryptogram"] } }
]
}
- constraints — a bounded JSON Schema over the instrument's OWN submitted
fields: `required` (presence) and `properties.<field>.enum` (allowed values,
a per-party runtime set, not a base-schema enum). Now that the non-schema
axes are siblings, `constraints` is finally a real, runnable schema over the
instrument; the mixed bag was not (a validator ignores unknown keys, so
`credentials` rules under it were inert).
- accepts — a uniform { attribute: [values] } menu of DERIVED or selected
options. A card's network is derived from the credential, not a submitted
field, so "accepted brands" is a menu the platform reads to OFFER options,
not a value constraint on a field. One map also keeps accepted-value menus
from proliferating as bespoke top-level keys.
- credentials — per-credential-type requirements as a typed list, applied by a
data lookup on the submitted credential's `type`. Never a schema `oneOf`: a
base `oneOf` cannot be widened by an extension `allOf`, so it would re-close
the credential-type vocabulary exactly like an enum. Unknown types are
handler/extension branches and pass through.
Schema: constraint.json = { required, properties (recursive via `$ref:"#"`),
enum }; type_constraint.json = { type, constraints }; the card branch pins
`type:"card"` (brands/credentials ride the open base); token_credential drops
its now-unused `$defs/constraint`.
|
Thanks for the deep thinking here Ilya. I think defaulting to using JSON schema fragments is a great observation, and it sent me down a rabbit hole of experimenting! While trying out these shapes, I ran into two rough edges I wanted to highlight and see if you have opinions on what "good" looks like, especially compared to how #424 solved for it. First, let’s look at billing address. For pure field requirements, the JSON Schema-shaped version works well: {
"type": "card",
"constraints": {
"required": ["billing_address"],
"properties": {
"billing_address": {
"required": ["postal_code", "address_country"]
}
}
}
} Where I get stuck is when the billing-address concern is still local to billing address, but is not itself a JSON Schema {
"type": "card",
"constraints": {
"required": ["billing_address"],
"properties": {
"billing_address": {
"required": ["postal_code"],
"allows_provider_address": true
}
}
}
} That keeps locality, but now constraints is no longer an honest JSON Schema fragment. The alternative seems to be {
"type": "card",
"constraints": {
"required": ["billing_address"],
"properties": {
"billing_address": {
"required": ["postal_code"]
}
}
},
"accepts": {
"billing_address_source": ["buyer", "provider"]
}
} That keeps constraints clean, but loses the locality that #424 had by letting billing-address-related constraints live Next, accepts vs credentials. I understand why brand moved out of constraints: it is an accepted/derived option, not a submitted field constraint. But This shape splits two “accepted option” concepts: {
"type": "card",
"accepts": {
"brand": ["visa", "mastercard"]
},
"credentials": [
{
"type": "pan",
"constraints": { "required": ["cvc"] }
},
{
"type": "network_token",
"constraints": { "required": ["cryptogram"] }
}
]
} Conceptually, I’m tempted by: {
"type": "card",
"accepts": {
"brand": ["visa", "mastercard"],
"credentials": [
{
"type": "pan",
"constraints": { "required": ["cvc"] }
},
{
"type": "network_token",
"constraints": { "required": ["cryptogram"] }
}
]
}
} I realize credentials are richer than brands: brand is a scalar menu, while credentials are typed branches with Overall, I have an opinion that because this approach still requires implementers to apply UCP-specific interpretation |
|
Agree on the conclusion and landed in similar spot after iterating through this. I'll close this, but I think there are some takeaways and patterns here that we should consider for #424 -- I'll leave it to your discretion though on if and which are worth bringing over. Tag me for final review once you feel #424 is ready. |
Alternative shape for the constraints work in #424 — stacked on this branch as a concrete variant, not a merge-over.
ucp-schema lint+ doc-example validation pass; net smaller than the current shape.The core proposal isn't a vocabulary change — it's factoring
constraintsby axis. The current singleconstraintsbag mixes several genuinely different things, which is what makes it hard to reason about. Pulling them apart resolves that and, as a bonus, makes each part a clean, honest mechanism.The insight: "constraints" is four different axes
required(+propertiesto nest)constraintsenumunderproperties.<field>constraintsacceptsoneOf)credentialsMixing all four under one
constraintskey is the confusion. Factoring them by key makes each honest.Shape
{ "type": "card", "constraints": { "required": ["billing_address"], "properties": { "billing_address": { "required": ["postal_code", "address_country"], "properties": { "address_country": { "enum": ["US", "CA"] } } } } }, "accepts": { "brand": ["visa", "mastercard"] }, "credentials": [ { "type": "pan", "constraints": { "required": ["cvc"] } }, { "type": "network_token", "constraints": { "required": ["cryptogram"] } } ] }Why factoring helps (not just tidier)
constraintsis now a genuine bounded JSON Schema over the instrument's own fields (required/properties/enum). In the mixed bag it wasn't — a validator run over it silently ignorescredentials(an unknown keyword), giving false confidence. Moving the non-schema axes out fixes that.brandsbecomes honest. A card's network is derived from the credential, not a submitted field, so "accepted brands" is a menu you read to offer options — not a field-value constraint.acceptssays that plainly; a uniform map ({ attr: [values] }) also stops accepted-value menus from proliferating as bespoke keys.oneOf. A baseoneOfcan't be widened by an extensionallOf— it re-closes the type vocabulary exactly like an enum (verified). A typed list resolved by lookup keeps extension credential types open with no dangerous keywords.