Skip to content

split instrument availability into constraints and options axes - #626

Open
igrigorik wants to merge 2 commits into
proto/funding-source-and-credential-constraintsfrom
feat/constraints-partitioned
Open

split instrument availability into constraints and options axes#626
igrigorik wants to merge 2 commits into
proto/funding-source-and-credential-constraintsfrom
feat/constraints-partitioned

Conversation

@igrigorik

@igrigorik igrigorik commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Current #424 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.

   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.
@igrigorik
igrigorik marked this pull request as ready for review July 22, 2026 04:14
@damaz91 damaz91 added the status:needs-triage Signal that the PR is ready for human triage label Jul 22, 2026
Comment thread source/schemas/shopping/types/card_payment_instrument.json
Comment thread docs/documentation/schema-authoring.md Outdated
Comment thread docs/documentation/schema-authoring.md
@carolinerg1 carolinerg1 added status:under-review gov:needs-tc-review and removed status:needs-triage Signal that the PR is ready for human triage labels Jul 22, 2026
   - 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.
@igrigorik

Copy link
Copy Markdown
Contributor Author

@jamesandersen good flags, addressed in 387eb33:

  • token_credential.json#/$defs/constraint: it does resolve; it's defined at $defs/constraint which is pre-existing code in proto branch -- this PR is not a replacement, it's a set of updates to apply on top.
  • "handlers": agreed, this doc is cross-domain — generalized to "most schemas", and marked the card example explicitly as "a payment handler".
  • required vs options keys: no,required names fields the buyer submits; options attributes like brands name values the seller accepts. A set like a card's brand is derived from the PAN post-submission, so there's no submittable field to overlay, which is exactly why it's options, not constraints.

@jamesandersen jamesandersen left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 raginpirate left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +22 to +24
"type": "array",
"items": { "type": "string" },
"uniqueItems": true

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@jamesandersen jamesandersen Jul 31, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+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?

Comment on lines -14 to -17
"billing_address": {
"$ref": "object_constraint.json",
"description": "Local constraint on the instrument's `billing_address` field."
},

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Without this, we'll fail to reject a constraint over field "billing_addr" when it should be "billing_address", right?

@jamesandersen jamesandersen Jul 31, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?").

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.

7 participants