feat: structured constraints for instruments and beyond#424
feat: structured constraints for instruments and beyond#424raginpirate wants to merge 3 commits into
Conversation
jamesandersen
left a comment
There was a problem hiding this comment.
@raginpirate thanks for putting #424 together — I do think this proposal works well:
- (+) Introduces a new credential type
network_token - (+) Required fields are explicit via the new
constraintprimitive- e.g. under instrument -> credentials -> constraints -> required_fields (more like where #288 started but now implemented at the right level)
- In contrast to where #288 was trending with implicit constraints via instrument -> constraints -> requires_card_verification and docs to explain intent at the credential level
- (+) Cross-domain reuse e.g.
address_constraint.json,credential_constraint.json - (-) Breaking change with removal of
card_number_type... left separate comment on this
On the "credential inception" point e.g. distinct constraint on a wire credential and - where applicable - the funding source underlying it. What do you think about taking this on in a separate PR? I'd like to better understand if there is a concrete scenario requiring both? e.g. can a business require a CVV on the underlying instrument while only accepting the network token? It's been a winding road so far to get alignment on updated constraint modeling for just the "wire credential" ... hopefully a follow-on for the underlying funding source could be quicker and cleaner after landing this.
TBH ... it took a while to grok this all (in large part for lack of good focus time) but I think the resolved examples are actually much easier than the schema suggests at first glance - just as another sanity check the multiple entries under credentials is how we address the hurdle that led us to the implicit bool and AVS enum on #288. Look right?
{
"available_instruments": [
{
"type": "card", // card-based instrument
"constraints": {
"brands": ["visa", "mastercard"],
"required_fields": ["billing_address"],
"billing_address": {
"required_fields": ["postal_code"] // e.g. AVS1, but adapts to other schemes
},
"credentials": [
{
"type": "card", // FPAN
"constraints": { "required_fields": ["cvc"] }
},
{
"type": "network_token", // DPAN / CPAN
"constraints": { "required_fields": ["cryptogram"] } // could add ECI if a business needs it
}
]
}
},
]
}| { | ||
| "type": "object", | ||
| "required": ["type", "number", "cryptogram", "eci_value"], | ||
| "properties": { |
There was a problem hiding this comment.
Thoughts about adding token_requestor_id (#296) onto network_token_credential while we're at it? not widely used as discussed on that PR but we do have the Braintree BYOT example
|
Schema read only. One validation gap to keep an eye on if this moves from prototype to spec shape: "constraints": {
"$ref": "constraint.json"
}Because of that, JSON Schema validation will not use { "type": "card", "constraints": { "required_fields": ["cryptogram"] } }
{ "type": "network_token", "constraints": { "required_fields": ["cvc"] } }Same issue for Suggested direction: either make The interoperability risk is that merchants/platforms may think constraint payloads are statically validated, while cross-axis mistakes only exist in prose. That matters for checkout because a mistyped funding-source requirement can become a failed tokenization/payment path rather than a schema error. |
|
Great catch @TateLyman — this is a real validation gap. As you noted, the Your A couple of alternative approaches that achieve the same schema validation, for consideration: Per-credential Single All three approaches reject the same invalid payloads — the difference is where the enforcement lives: central dispatch ( @raginpirate WDYT? |
1fa04e8 to
6b04550
Compare
|
Thanks for the comments folks; totally agree with the schema validation concern. This actually goes back to the original payment handler design; we did not associate instruments with any credential types as an attempt to show they are open objects to be associated as needed. In that same vein, I did not originally try having machine-enforcable types for the constraints, but honestly I think this is just a gap we can close. In my latest revision I've actually taken the oneOf approach for both constraints as well as credentials themselves 👍 I wanted to get this into review this weekend but it took quite a bit of time shredding my original proposal apart here: one of the main contentious points is "funding sources" as you pointed out @jamesandersen, and in trying to isolate it I reverted my world view on the concept and moved it into a type of constraint specific to token credentials. I'm feeling good about the work in this PR, and my steps to get this ready to publish are:
One interesting note is still how we refactor the card credential: I think the right path is to actually entirely deprecate it and just introduce a PAN credential and a network token credential. Its technically non-breaking too because card is isolated and attached to nothing in the base spec; handlers in the wild using it can just keep referencing it from the old api versions without concern if they really want to. WDYT about this or the plan above? |
@raginpirate makes sense to me ... if introducing distinct PRs I think this will become more digestible ;-)
I do think this is going to be easier to reason about in the long run |
6b04550 to
2371672
Compare
2371672 to
446e45f
Compare
igrigorik
left a comment
There was a problem hiding this comment.
Catching up, great discussion. The direction makes sense, I agree that modelling this as booleans doesn't scale and pollutes the schema. Reading the current shape, though, I hit a number of gotchas and I'm wondering if we're over-promising here.
Meta issue: constraints isn't actually generic
The pitch is a universal primitive ("every constraint object in UCP follows this shape"), but it isn't one — it's a shape you have to graft onto every object that negotiates input. That's a mixin re-attached per-object, not a generic contract, and it doesn't scale as the surface grows; we'd have to include this on every object at the limit.
#564 is the early evidence: it reuses the primitive as fulfillment_available_method.constraints.destination, but destination isn't a field of the fulfillment method, so it already breaks the base contract ("required_fields names properties of the constrained object").
If it is meant to be generic, it has to be extensibility-friendly
This rules out enums / closed dispatch. allOf can only intersect, never widen — so any closed construct in the base permanently excludes extensions:
- Enum:
address_constraint.required_fieldsis a closed enum of the 9 canonical fields.postal_address.jsonis open, so a handler can addtax_id— but can never require it ("required_fields": ["tax_id"]is rejected). A "declare what you need" primitive that can't name an extended field isn't extensibility-friendly. - Closed dispatch: validating a typed family by enumerating known types in the base rejects extension types. The
credentialsoneOfhere avoids that only by adding an open catch-all branch +type_constraintto stay open.
And the validation this complexity buys isn't actually enforced. The brands and credentials rules live on the card schema, but a handler declaration validates against the base available_payment_instrument.json — which never dispatches on type == "card" to pull those rules in. So they go unchecked: the documented business_schema example still passes the repo's own validator even with brands broken (change ["visa","mastercard"] to a bare "visa" and it's still valid). Same gap @TateLyman/@jamesandersen flagged for credentials, one level up.
Stepping back: are we over-generalizing this problem?
Reactive feedback is the established mechanism, and it already handles dynamic requirements. Submit what you have → checkout.status: "incomplete" + messages[] with message_error.path (RFC 9535 JSONPath) and severity: "recoverable" ("fix inputs and retry via API"). Zero new schema, works with extensions and responds to dynamic requirements of the current negotiation. This does put an obligation on handlers to validate and return messages before tokenizing (rather than failing at the PSP) — but that's a smaller, more local ask than a new schema primitive. The main gap is that it costs a roundtrip.
I'm not against modeling some pre-submit requirements. An upfront signal is genuinely useful, so a platform can collect complete input (and render the right fields per option) before the first submit. Also agree that bools were the wrong mechanism.
Alt, what if: model it as requires array of JSONPaths...
{
"type": "card",
"accepts": {
"brands": ["visa", "mastercard"],
"credentials": [
{ "type": "card",
"requires": ["$.payment.instrument.credential.cvc"] },
{ "type": "network_token",
"requires": ["$.payment.instrument.credential.cryptogram"] }
]
},
"requires": ["$.payment.instrument.billing_address.postal_code"]
}- One vocabulary across
requiresandmessage_error.path requiresis a minimum, not a completeness guarantee- Models arbitrary requirements without introducing N booleans
- Applied to payments, can be selectively adopted in other places if/as necessary
Net: no constraint.json / type_constraint.json / address_constraint.json, no enum, no per-object algebra, requirements separated from capabilities, and nothing to misapply (so #564 doesn't arise). This pattern can be applied selectively to other places too, but we would use it surgically in select places, instead of promising a generic contract that we can't deliver.
|
Appreciate this @igrigorik — good callout that even the landed On the JSONPath direction: worth naming that its main weakness — unvalidated paths (a The one thing I'd hold firm on is the need for an upfront signal. The anchoring use case from #288 is a platform knowing before it submits complete checkout whether a business' payment handler requires CVV (a per-merchant setting on handlers like @raginpirate looks like this would still build on the distinct |
|
@jamesandersen — agreed on both, and I think we can close the typo concern rather than eat it... Upfront signal: yes, Credential types: On the typo gap: rather than an enum on the wire (which can't name extension fields), we can teach |
446e45f to
9495857
Compare
|
Thanks for the feedback @igrigorik and @jamesandersen! ❤️
Now... hear me out, I am not sold on the shift from
Maybe to help drive it home, the $path based proposal: {
"available_instruments": [
{
"type": "card",
"constraints": {
"brands": ["visa", "mastercard"],
"credentials": [
{
"type": "pan",
"requires": [
"$.payment.instruments[].credential.cvc"
]
},
{
"type": "network_token",
"requires": [
"$.payment.instruments[].credential.cryptogram"
]
}
],
"requires": [
"$.payment.instruments[].billing_address.postal_code",
"$.payment.instruments[].billing_address.address_country"
]
}
}
]
} The localized required_fields proposal: {
"available_instruments": [
{
"type": "card",
"constraints": {
"brands": ["visa", "mastercard"],
"required_fields": ["billing_address"],
"billing_address": {
"required_fields": ["postal_code", "address_country"]
},
"credentials": [
{
"type": "pan",
"constraints": {
"required_fields": ["cvc"]
}
},
{
"type": "network_token",
"constraints": {
"required_fields": ["cryptogram"]
}
}
]
}
}
]
} Very similar but I think the later is significantly less noisy, and your earlier feedback greatly improved the complexity and correctness of the final solution. WDYT? I'm happy to be pushed back on if theres a benefit I've missed on the fullpath here. Also happy to rename required_fields to requires if you think thats a better name! |
|
@raginpirate good turn, I think we're converging on right shape. As a wildcard thought experiment, what if we push this one step further: #580 |
|
Really like where this landed, @raginpirate — dropping Good call on keeping local The one item I don't think is closed yet is the enforcement gap:
That's the same "validation isn't actually enforced" point from earlier, still true on HEAD. The fix is the same // what available_instruments[].items validates against — a dispatcher, not the bare base
{
"if": { "properties": { "type": { "const": "card" } }, "required": ["type"] },
"then": { "$ref": "card_payment_instrument.json#/$defs/available_card_payment_instrument" }
// unknown instrument types fall through to the open base — extension-safe, same as your credential dispatch
}That turns the |
Description
Larger-scope continuation of #288
Built ontop of by #564 and #565 (WIP)
Introduces a single constraint primitive (constraint.json) and applies it consistently across instrument and credential availability. Replaces ad-hoc booleans that implementers might have used (requires_card_verification,
billing_address_granularity) with a uniform shape: required_fields[] plus domain-specific custom keys.
New primitives
Primitive applications
Category (Required)
Please select one or more categories that apply to this change.
Checklist
!for breaking changes).