feat!: Express instrument requirements with request constraints, split PAN and Network Token credential types - #424
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
}
]
}
},
]
}|
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
acf608b to
bbd2a24
Compare
bbd2a24 to
26a9300
Compare
|
Good doc suggestion @jamesandersen, thanks and adopted! This is now based on top of the anyOf PR which simplifies the shape in our examples 😄 |
|
Still working through the details, but quick sanity check: From the guide example — the two constraint slots sit side by side (trimmed): {
"type": "card",
"constraints": { "brand": { "enum": ["visa", "mastercard"] } }, // ← bare map
"ucp": {
"request_constraints": { // ← Constraint Expression
"path": "$['payment']['instruments'][?@['type'] == 'card']",
"required": ["billing_address", "credential"],
"properties": {
"billing_address": { "required": ["postal_code", "address_country"] }
}
}
}
}
If we're reusing the grammar, "constraints": {
"properties": { "brand": { "enum": ["visa", "mastercard"] } }
}I believe the intent was to reuse the grammar? |
26a9300 to
885fc27
Compare
899935e to
e8da3e8
Compare
|
Added a minor non-blocking comment, also seems like there is a merge conflict with |
885fc27 to
6a668e1
Compare
|
Ah, documentation whiplash! Good catch @amithanda, ready for a final review! |
Larger-scope continuation of #288, now stacked on #655. Earlier iterations are #580 (axis factoring), #626 (
constraints/optionspartition), #564 (fulfillment application) and #565 (credential split).What changed since the original proposal. This PR opened by adding three new primitives —
object_constraint.json,value_constraint.json,type_constraint.json— and modellingavailable_instruments[]as an array of Type Constraints. Review established that a constraint primitive grafted onto every object that negotiates input is not a generic contract (#424 review), that closed enums and closed dispatch permanently exclude extensions becauseallOfcan only intersect, and that the validation the complexity bought was never actually enforced. #655 then landed a single closed Constraint Expression grammar and bound it to request data.So this PR now adds no additional constraint primitive at all. It makes the requirements this arc was about expressible with the grammar that already exists, by fixing the two things that were blocking it: credential variants that could not be addressed positionally, and a protocol member that could only carry one rule.
Changes
pan_credentialandnetwork_token_credential. Different PCIscope, different required fields, different verification data.
card_credential— which folded both behindcard_number_type— isdeprecated, not removed.
card_payment_instrument.network, the card scheme elected for atransaction. A submitted field, present when a co-badged selector was shown.
constraint_target.brand, representing the card scheme accepted. A value the Businessderives from the account number, so it survives tokenization and is not a
submitted field. This is the target for the standard constraint object on every available instrument.
Example: Require a CVC for a PAN but a cryptogram for a network token
The anchoring ask from #288, and the one concern (C4) left open on #655. One path
selects the submitted instrument and a nested
anyOf(#757) expresses the twocredential branches: splitting the credential types is what gives each branch a
stable discriminator, so no rule has to branch on a sibling field.
{ "type": "card", "constraints": { "properties": { "brand": { "enum": ["visa", "mastercard"] } } }, "ucp": { "request_constraints": { "path": "$['payment']['instruments'][?@['handler_id'] == 'processor_1' && @['type'] == 'card']", "required": ["network", "billing_address", "credential"], "properties": { "billing_address": { "required": ["postal_code", "address_country"] }, "credential": { "anyOf": [ { "properties": { "type": { "const": "pan" } }, "required": ["cvc"] }, { "properties": { "type": { "const": "network_token" } }, "required": ["cryptogram", "eci_value"] } ] } } } } }One declaration covers all three of the use cases worked through on this PR:
CVV on a card, AVS fields on the billing address, and a network-token cryptogram.
Requirements shared by every card instrument are sibling members; the ones that
differ per credential family are branches. A network token is never asked for a
cvc, and a PAN is never asked for acryptogram.brandsits inconstraintsbecause it is derived; every requirement above targets a field a Platform
actually submits.
Where the review concerns land
Numbering follows the summary on #655.
billing_addr)requires_card_verification)anyOf; see the example above.typediscriminator didn't drive validationavailable_*definition and declares the optionalucpproperty. Both now documented.constraintsisn't truly genericconstraint_expression.json, unchanged.constraints, wire data goes torequest_constraints.optionsover-strict typingoptionsaxis.propertieskeyword collisionNot in scope
Requirements on what a token represents (C7). A token credential carries an
opaque token, so the credential it was minted from is not in the payload. This requires a bit of a taxonomy shift and the hardest part of the problem is wanting to evaluate "required": [] constraints for elements NOT being actively negotiated over the wire; requiring payments to commit to minting a potentially unique constraint application.
Category (Required)
Please select one or more categories that apply to this change.
Checklist
!for breaking changes).