split instrument availability into constraints and options axes - #626
split instrument availability into constraints and options axes#626igrigorik wants to merge 2 commits into
constraints and options axes#626Conversation
The prior shape put everything an instrument declares — presence, per-field
constraints, accepted card brands, and credential families — into one
`constraints` bag. Prototyping a resolver against that shape showed the cost:
to enforce field requirements a consumer must first classify every key by
resolving its concrete schema (is this a field constraint? a value menu? a
typed family?). That classification is the whole problem — it needs the target
schema, defeats "compile from data alone," and produced real bugs in the
prototype (per-key allOf composition, and a `required` name collision).
Partition the bag into two sibling axes:
- `constraints` — field requirements over the instrument's OWN fields (an
Object Constraint: `required` plus one direct key per constrained field).
Compiles to a JSON Schema overlay (`required` + `properties`) a standard
validator runs.
- `options` — accepted value menus (`brands`) and typed families
(`credentials`), resolved by data lookup and never part of the overlay.
With the menus and families moved to `options`, `constraints` holds nothing but
`required` and field constraints — and `object_constraint` now enforces exactly
that: every non-`required` key is a nested Object Constraint or a Value
Constraint (recursing via `$ref "#"`). Compiling the overlay is therefore a
pure data walk — no schema resolution, no classification. That is the win: the
resolver is clean by construction, not by careful bookkeeping. Proven in the
ucp-schema resolver prototype (~20-line compile; the compiled overlay enforces
presence and allowed values against real instances).
Trade-offs:
- `constraints` is deliberately not a literal JSON Schema: fields are direct
keys, so a consumer compiles it (take `required`, lift field keys under
`properties`). A `properties`-keyed sub-map would make it literal but collide
the JSON Schema `properties` keyword with the data key when a schema narrows a
nested field. The compile is trivial, and a resolver runs regardless.
- `required` is reserved inside a constraint object, so a field literally named
`required` can be listed as present but cannot carry a nested constraint.
Rare, and documented.
- The shape still does not enforce name resolution (does the named field exist
on the target?). Unchanged from before; a lint pass can add it later — the
partition makes it tractable, since every non-`required` key is unambiguously
a field name.
Migration: the stricter `object_constraint` (every non-`required` key must be a
field constraint) surfaced five stale example docs still using
`constraints: {brands}`; migrated them to `options: {brands}`. schema-authoring
and payment-handler-guide are rewritten to the two-axis model, and the
availability wire example is now validated by the example harness instead of
skipped.
- Answer whether `options` keys belong in `required` (they do not): add one
line to the `options` bullet stating the split — `required` is what the buyer
must send (presence of submitted fields), while `options` attributes like
`brands` name values the seller accepts, not fields the buyer sends.
- Generalize the cross-domain Constraint Objects prose: "most handlers" ->
"most schemas", and mark the card example as "a payment handler", so payment
framing does not leak into the general primitive docs.
- Drop "menu" throughout (options bullet, handler-guide table, schema
descriptions) in favor of the precise terms already in use — "acceptable
set" / "scalar list" / "accepted values" — so `options` introduces no
undefined concept.
|
@jamesandersen good flags, addressed in 387eb33:
|
There was a problem hiding this comment.
Thanks for the quick clarifications @igrigorik
I think this culmination of the various explorations and feedback from @raginpirate and @alexpark20 is a good resolution to the problem that prompted the original PR (#288 ) and nicely scales to other use cases. I appreciate everyone's help here.
raginpirate
left a comment
There was a problem hiding this comment.
I appreciate the direction you’ve steered this in, Ilya. I’ll be non-blocking on this moving forward.
I do want us to name the primary trade-off: a more fragmented API shape in exchange for simpler constraint-to-overlay compilation. In some cases, the requiredness and accepted shape of the same target are split across mirrored paths (constraints.required: ["credential"] alongside options.credentials) which weakens locality.
That may be a fair trade-off. However, resolving an instrument’s type already requires selecting its concrete branch from the negotiated handler schema, so I question how much end-to-end complexity this saves. Put another way: this branch removes per-key role classification from the overlay compiler; it does not remove handler-schema resolution, branch selection, target resolution, or end-to-end validation complexity.
| "type": "array", | ||
| "items": { "type": "string" }, | ||
| "uniqueItems": true |
There was a problem hiding this comment.
This seems problematic to close the supported additional properties set, right?
I'm imagining other data types are acceptable keys to continue extending onto available instrument.
There was a problem hiding this comment.
+1. The docs describe options as a uniform map — "a scalar list (e.g. brands) or a typed family (e.g. credentials)" — but the schema only makes the scalar case extensible: additionalProperties is {array of strings}, so credentials validates only because it's hardcoded as a named property. Any other typed family under a new key would fail.
I'd suggest widening additionalProperties to admit either shape (distinguished by item type, so still a pure data-shape check):
"additionalProperties": {
"anyOf": [
{ "type": "array", "items": { "type": "string" }, "uniqueItems": true },
{ "type": "array", "items": { "$ref": "type_constraint.json" }, "uniqueItems": true }
]
}With this, credentials no longer needs to be a named property either — it just becomes the canonical typed-family value, and options is genuinely uniform (shape decides dispatch vs. membership). The tradeoff is losing credentials as signposting for a known family, so worth keeping it named if we'd rather advertise it explicitly.
Otherwise, if options is meant to be scalar-only with credentials a deliberate one-off, let's tighten the docs to say so. Either way, schema and prose should agree — which did you intend, @igrigorik?
| "billing_address": { | ||
| "$ref": "object_constraint.json", | ||
| "description": "Local constraint on the instrument's `billing_address` field." | ||
| }, |
There was a problem hiding this comment.
Without this, we'll fail to reject a constraint over field "billing_addr" when it should be "billing_address", right?
There was a problem hiding this comment.
If I follow correctly, I think this is safe to remove — the field-name check belongs against the target schema (payment_instrument, where billing_address actually lives), not against available_payment_instrument. That's the lint @igrigorik defers in Trade-offs ("does the named field exist on the target?").
Current #424 shape put everything an instrument declares — presence, per-field
constraints, accepted card brands, and credential families — into one
constraintsbag. Prototyping a resolver against that shape showed the cost:to enforce field requirements a consumer must first classify every key by
resolving its concrete schema (is this a field constraint? a value menu? a
typed family?). That classification is the whole problem — it needs the target
schema, defeats "compile from data alone," and produced real bugs in the
prototype (per-key allOf composition, and a
requiredname collision).Partition the bag into two sibling axes:
constraints— field requirements over the instrument's OWN fields (anObject Constraint:
requiredplus one direct key per constrained field).Compiles to a JSON Schema overlay (
required+properties) a standardvalidator runs.
options— accepted value menus (brands) and typed families(
credentials), resolved by data lookup and never part of the overlay.With the menus and families moved to
options,constraintsholds nothing butrequiredand field constraints — andobject_constraintnow enforces exactlythat: every non-
requiredkey is a nested Object Constraint or a ValueConstraint (recursing via
$ref "#"). Compiling the overlay is therefore apure data walk — no schema resolution, no classification. That is the win: the
resolver is clean by construction, not by careful bookkeeping. Proven in the
ucp-schema resolver prototype (~20-line compile; the compiled overlay enforces
presence and allowed values against real instances).
Trade-offs:
constraintsis deliberately not a literal JSON Schema: fields are directkeys, so a consumer compiles it (take
required, lift field keys underproperties). Aproperties-keyed sub-map would make it literal but collidethe JSON Schema
propertieskeyword with the data key when a schema narrows anested field. The compile is trivial, and a resolver runs regardless.
requiredis reserved inside a constraint object, so a field literally namedrequiredcan be listed as present but cannot carry a nested constraint.Rare, and documented.
on the target?). Unchanged from before; a lint pass can add it later — the
partition makes it tractable, since every non-
requiredkey is unambiguouslya field name.