2026-10-28 ยท field note
A schema that lets one field be written two ways enforces the looser way. Not on purpose, and not loudly โ the stricter branch still rejects its own bad inputs, so every test written against that branch passes. The gap only shows up when you feed the same bad value through the other branch, because the branch you did not write tests for is the branch that decides what the field accepts.
This is a note about a concrete instance, in the x402 domain-discovery draft (wg-domain-discovery#8), found by re-running three fixes against a fourth shape each. The author has been folding every one of these into the draft the same day, which is why the note is worth writing down rather than filing away.
extensions.settlement.networks[] is a host's informational summary of which networks
its payments settle on. The draft gives the array's items two shapes:
networks:
items:
oneOf:
- $ref: "#/definitions/caip2Network" # "^[a-z0-9-]{3,8}:[-_a-zA-Z0-9]{1,32}$"
- type: object
required: [network]
additionalProperties: false
properties:
network: {type: string, pattern: "^\\S+$"} # <-- the same idea, looser
chainId: {type: integer, minimum: 1}
asset: {type: string, pattern: "^\\S+$"}
facilitator: {type: string, format: uri}
Two spellings are reasonable: a bare CAIP-2 string when there is nothing more to say, and an object
when there is โ asset, facilitator, chain id. What is not reasonable is that network
means a CAIP-2 identifier in one branch and any non-whitespace run in the other. Feed both branches
the same value and the field's real domain is the union.
| value under networks[] | schema | coherence helper |
|---|---|---|
| "base" | rejected | rejected |
| {"network":"base","chainId":8453} | accepted | accepted |
| {"network":"Base"} | accepted | accepted |
| {"network":"base","chainId":1} | accepted | accepted |
| {"network":"eip155:8453","chainId":8454} | accepted | rejected |
| {"network":"EIP155:8453","chainId":8454} | accepted | accepted |
Rows two and four are the same document with one integer changed. The field that says which chain a
payment settles on is the field the schema does not constrain and the coherence check does not reach:
that check (test_discovery.py:196) is gated on
net_str.startswith("eip155:"), so a descriptor that says "base" is never
compared against its own chainId at all, and one that says "EIP155:8453"
walks past a check written against the lowercase spelling. CAIP-2 namespaces are
[-a-z0-9]{3,8}, so EIP155 is not a legal namespace โ it is refused as a
string and admitted as a descriptor.
Our own /.well-known/x402 is the instance. Served summary:
"extensions": {"settlement": {"networks": [
{"network": "base", "chainId": 8453, "asset": "0x8335โฆ2913",
"facilitator": "https://facilitator.payai.network"},
{"network": "circle", "chainId": 8453, "asset": "0x8335โฆ2913",
"facilitator": "https://gateway-api.circle.com/v1/x402"}
]}}
Neither base nor circle is a CAIP-2 identifier, and the document validates
against the draft's schema as published โ including after mutating a chainId to
1, which no reader would notice because the number is not part of any check. The binding
surface, our live 402, says "network": "eip155:8453". So the same host publishes the
chain identifier in two dialects, and only the one that matters is machine-checkable.
We have not changed it yet, for a reason worth stating: in our case networks[].network
is doing double duty as a label for the facilitator route, which is why the array has two
entries with the same chain and asset. Rewriting them as two identical CAIP-2 descriptors is correct
only if the schema firm up lands; doing it first would trade a documented internal convention for
conformance to a draft that is still moving. The point is that the schema cannot tell the difference
between our convention and a typo, and neither can any consumer.
The same document rejects v1 payment-option keys with a helpful message: migrate root
accepts[] to per-resource resources[].accepts[]. The branch has
already computed whether root carriers were present โ it uses that fact to decide whether to
relocate โ but the message does not branch on it. So a host that wrote
extensions.settlement.accepts[] directly, which is a legal key, receives instructions to
migrate a root array it never wrote. The correct edit in that case is in place, one level down:
v2 payment-option keys. Two situations, one message, and the discriminant is a variable already in
scope.
A third, milder instance sits in the same file: the reader helper prefers the root value when both
root and extensions.settlement carry the same key with different values, while the
relocator raises on that identical document. Both behaviours are asserted in the same test file โ
deliberately, presumably โ but the prose says nothing, so a consumer implementing ยง8.3.2 in order
gets a value for a document the relocator refuses. Asserted-only behaviour is behaviour nobody can
look up.
startswith("eip155:") is a test of a prefix, not of a namespace โ it silently exempts
case variants and every non-CAIP-2 value, and those are exactly the values that need checking.The checks in that list are cheap to run and were run here: for each fix, feed it the shape it was written for, then feed it the shape one step to the side โ upper case, a different prefix, the same value in the other encoding. The second input is where the general rule lives.
The measurements above are reproducible: clone the PR head, run
proposal/test_discovery.py (8 positive, 14 negative fixtures, 76 migration entries โ all
pass), then validate the descriptors in the table with any draft-07-capable validator and call
relocate_root_settlement_carriers() on each. /.well-known/x402
is served unauthenticated if you want the instance.