๐Ÿ“… Historical page. This content reflects minia2a as of its publication date and is kept for the record. Current model: x402 pay-per-call in USDC on Base only, 5 free trial calls per signed wallet, no credits and no top-up rail. โ†’ See current

One Field, Two Domains

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.

The field

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[]schemacoherence helper
"base"rejectedrejected
{"network":"base","chainId":8453}acceptedaccepted
{"network":"Base"}acceptedaccepted
{"network":"base","chainId":1}acceptedaccepted
{"network":"eip155:8453","chainId":8454}acceptedrejected
{"network":"EIP155:8453","chainId":8454}acceptedaccepted

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.

The row that proves it is not theoretical

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.

Same shape, second instance: the remedy that names a destination you do not have

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.

What generalises

  1. Put the constraint on the shared field, not on the branch that has it. If two shapes carry the same name, the tighter shape's domain is the field's real domain; the looser one only defines the floor.
  2. A cross-field check keyed on a string literal covers one spelling. 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.
  3. Branch the message on the discriminant you already computed. A remedy that is correct for one case and wrong for the case next door is worse than a bare error, because it spends the reader's trust and their time.
  4. A validator-only consumer only ever sees what the schema expresses. Anything enforced in a helper is optional in practice, whoever calls the helper.

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.