Skip to content

About

Open-path x402 conformance: _x402 TXT grammar, /.well-known/x402 manifest diagnosis, and a live census with negative controls. Offered to x402-foundation/x402#3104.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

x402 discovery checks

The open-path half of x402 conformance: can a service be found without going through a registry?

Two legs, both read-only:

  1. _x402 TXT record — the underscored node name, registered with IANA on 2026-08-11 under the RFC 8552 registry, referencing draft-hawkins-x402-dns-discovery.
  2. /.well-known/x402 manifest — the document the record points at, diagnosed field by field.

Offered to x402-foundation/x402#3104 (the x402-doctor proposal). MIT, take what you want: vendor it, fork it, or point at it.


What a census of the live population found

conformance/discovery-census.json — 1,623 distinct hosts from 15,263 Bazaar listings, 2026-08-24T11:49Z.

Every count below is an interval, and this is not a hedge. A host whose read does not settle can only ever be MISSING from a class, never added to one, so a single draw yields a floor rather than a count. The width is the number of hosts the run could not grade — a lookup that SERVFAILed, a fetch that timed out, or a host whose own catch-all control fired. conformance/series.csv carries the same floors and ceilings, and CI refuses to commit a run that emits a bare number (census/interval-guard.ts).

hosts domains
serve a document at /.well-known/x402 894–958 408
…structurally valid 4–68
…fail only on kind 424–488
…also missing x402Version 466–530
publish a conformant _x402 TXT record 1–12
publish a near-miss at _x402 5–16
records pointing off-domain 0

64 hosts could not be graded on the manifest leg (21 unreachable, 43 blinded by their own control) and 11 on the DNS leg; that is where the width comes from.

Both denominators are published because one operator runs 75 of those hosts. 468 domains are single-host.

A count and a comparison are different claims. Two draws may be subtracted only when their frame digests match and their intervals are disjoint — the census prints the honest sentence itself rather than leaving it to be written by hand beside the numbers. This is @novadyne-hq's rule from x402#3220, adopted whole: publish the attribution, or publish an interval — a re-census is not a fixed point. We publish both; the artifact carries the full sorted host list and its sha256, so anyone can compute the turnover instead of taking our word for a delta.

The manifests are not one framework's default. They are hand-authored and mutually inconsistent — different field names, different structures, a shared version: 1 convention that no specification asked for. Several hundred operators independently chose to publish a discovery document at the same location. 424–488 of them are one field away from validating.

⚠️ An earlier version of this paragraph said each operator invented its contents. That claim is withdrawn. @Circadian-agent showed with a primary source (novadyne's commit 8470d820) that the shared version: 1 convention tracks an indexer's document shape rather than independent invention, and @novadyne-hq's conditioning on shape made the effect we had offered as evidence change sign. The observation that stands is the inconsistency; the claim about its origin does not, and it is left struck here rather than quietly deleted.

Five hosts publish at _x402 under a grammar that is not the draft's — a floor, not a total — in three incompatible syntaxes, every one pointing at its own domain:

v=x4021;descriptor=api;url=https://api.telemost.io/.well-known/x402
v=x4021;url=https://tablint.dev/.well-known/x402
x402-manifest=https://vibesprings.net/.well-known/x402.json
https://api.auor.io/.well-known/x402            (bare URL, no key at all)

Two of those differ from a conformant record by exactly two tokens. People are reaching the same design without being told to. The risk to the name is not that anyone attacks it — it is that several people spell it differently and every implementation silently skips what it does not recognise.

So the checker reports a near-miss with the specific tokens instead of returning nothing. The gate is unchanged; only the diagnosis is generous.


Controls, and why they are not optional

A negative control cannot detect a reader that returns nothing. Every run asserts a known-positive first and refuses to emit a census if none returns a record. A sweep that cannot demonstrate it can see a record that is there is not a weak result — it is not a result.

Three failures the controls caught here, each of which had already changed a published number:

The first known-positive was wrong, and the mistake found a spec gap. It used api.flareclaw.app; the record lives at the apex, pointing wk= at a subdomain. A census asking _x402.<exact-host> records the draft's own author as a non-publisher. _x402.<domain> does not say which domain a consumer holding a resource URL should ask for. Any checker inherits that.

An ancestor walk fixes that and creates something worse. A host that does not exist inherits its parent's record. On shared hosting the parent belongs to the platform, so one record turns every tenant into an adopter. Ancestor hits stay a separate verdict and are never merged.

Per-host negative controls changed the answers, not the confidence. The first run reported 18 hosts publishing a rival _x402 record. The real number is 5. The rest were zone wildcards answering every invented name — one returns an SPF record, another a Google site-verification string. Eight hosts likewise serve a body for /.well-known/{invented}, so what their /.well-known/x402 represents cannot be established; those are reported uninformative and dropped from the cross-tabulation rather than graded. Grading a catch-all as invalid puts a defect on someone who never claimed to publish anything.

Every number here got smaller as the controls got stricter.


Vectors

vectors/txt-grammar-vectors.json — ten TXT records with expected verdicts. Fixtures naming a host and a date are real records observed in the wild; synthetic ones say so. The two foreign cases are genuine wildcard replies, included because a resolver that reads them as x402 records invents adopters.

Regenerate rather than hand-edit:

npm run vectors > vectors/txt-grammar-vectors.json

A published count is an interval, or it is not published

census/interval-guard.ts refuses to let a run commit a point estimate. It is wired into the weekly workflow before the commit step, so a violation leaves the repository untouched rather than leaving a bare number in a file the working group reads.

What it enforces:

  • every artifact carries a published block, and every leaf in it is a bound — {observed, indeterminate, lo, hi, basis}. A bare integer is refused, and so is an interval whose slack has been edited away;
  • every artifact carries a frame.digest — sha256 over the sorted host list — so two draws can be shown to have graded the same population before anyone subtracts them;
  • conformance/series.csv carries _lo/_hi for every column whose values are numeric. The rule bites on values rather than names, because a rule you can satisfy by renaming a column is not a rule;
  • a delta may claim a direction only when the two intervals are disjoint. npm run series:compare generates that sentence instead of leaving it to be written by hand: 27–33 → 24–29, −9 to +5 (direction not established).

The guard is tested in both directions before it is trusted — clean artifacts that must pass, and separately defective ones that must each be caught by name, so a guard that rejects everything for one reason cannot score a pass — because a guard that has never fired is indistinguishable from a guard that cannot. npm run guard:selftest, and CI runs it before the census does.

What an interval does not fix, said here so it is not mistaken for a solved problem: the sampling rule is a window on a catalogue that turns over, not a cohort. Slack from ungraded reads and membership churn are different defects. The frame digest and the published host list are what close the second one — which is why both are emitted, not either.

The control battery

The per-host control already runs against every host in the frame, on both legs, every draw — so pointing it at more named hosts adds no coverage. What api.hergertsynthora.com gave us on #3220 was not a host, it was a known-answer case: someone else had independently established what that host does, so it could tell a working control from a broken one. A predicate is verified by a case that discriminates, never by a bigger n.

census/control-battery.ts is that idea made into a table. 16 cases of declared response shapes, each carrying where it came from, run against the real predicate and — this is the part that matters — against the real manifestLeg driven over scripted responses through an injected fetcher, so the battery exercises the shipped code path rather than a lookalike.

It fails in both directions, against 10 mutants:

  • every mutant must die. Each broken gate models a mistake somebody makes or has already made — including the historical one, modelled exactly as it was written (fire only when the control body parsed). A way of breaking the control that no case notices is a hole in the battery and is reported as one.
  • every case must be able to fail. A case no mutation can make fail is a dead arm: it reads as protection in review, and only deleting it tells you the difference. One was deleted from this project on 2026-08-23 for exactly that.

The leg cases assert one observable beyond the verdict: what the document read was recorded as, independent of what the host was graded. The original defect was invisible to the predicate alone — controlFired was correct in isolation, and the bug was that the leg consulted the body. A blinded host whose document is a valid manifest must come back uninformative_soft_200 and carry doc.shape: valid: recorded, not graded. A leg that consults the document when the control fired returns valid there, and that is caught directly.

One property it pins rather than fixes, stated here so it is not a surprise:

  • a 500 or unreachable control is still graded. That is the same defect shape — "the control did not fire" and "the control could not be evaluated" sharing one value — surviving in the last place it lives. The census records ctrl.status on every host, so the size of the class is measurable from the artifact today, and census/score-rules.ts measures it (R3 vs R3s below).

And one it used to pin and now measures:

  • the false blind. A host that answers the catch-all and serves a real manifest is blinded — the verdict is right, we are not entitled to grade it — but a real publisher leaves the count. Until 2026-08-23 the manifest was never read on such a host, so the size of that class was unmeasurable by construction. The census now reads the document on every host and records what it would have graded as in doc.shape, while the verdict stays blinded; the class is a count on the artifact and the scorer prints it.
npm run battery           # 16 cases, 10 mutants, zero network
npm run battery:verbose   # every case and which mutants each one kills

Rule against rule, from the artifact

Three instruments on #3220 blind a host by three different rules — a status set (novadyne), status-plus-media-type (Circadian), a 2xx alone (ours) — and a fourth, artifact identity (the control returned the same bytes as the document, or itself parses as a manifest), was proposed as the sharper one. @novadyne-hq scored all of them on a pinned 1,711-host union; our side of that comparison is to score the same rules against our weekly census without sweeping anyone's hosts a second time.

So from artifact 0.2.0 every row carries a ctrl and a doc read record — status, bare media type, body length, sha256, and what the body would grade as (shape) — and the rows are no longer filtered to the interesting ones, because a rule that blinds on a 4xx body needs the absent hosts as much as the publishers. Bodies are never stored. census/score-rules.ts reads that file and nothing else:

npx tsx census/score-rules.ts conformance/discovery-census.json --out conformance/rule-comparison.json
npx tsx census/score-rules.ts --selftest     # reproduces novadyne's four-host table before scoring anything

Every rule in it is a reconstruction of another instrument's published rule and is labelled with its source; the rule text travels in the output so a correction can name the line. The failure policy is held fixed in novadyne's words — control-fetch failure ⇒ UNREADABLE, every rule — and ours appears twice: once under that policy, so its column is comparable, and once as shipped, so the asymmetry is a number and not an argument. The selftest's known-positive is the four disputed hosts from the thread: if the scorer cannot reproduce a table two other instruments already agree on, its columns mean nothing on 1,611. The weekly Action writes conformance/rule-comparison.json after the census commits, and fails after publishing, never instead of it.

The ancestor walk, and the number an I-D quotes

conformance/ancestor-walk.json is the measurement behind draft-hawkins-x402-dns-discovery §5. It exists because those numbers were living in a GitHub comment and in the draft, and nowhere else — a hand-written count with no generated block behind it, which is the defect this repository spends most of its time reporting against other instruments. An I-D revision is immutable once uploaded, so it is the worst place in the world to keep one.

Over 1,611 catalogued hosts the walk visits 2,234 distinct names, and 381 of those hosts (23.6%) reach an ancestor that is itself a public suffix — workers.dev (108), vercel.app (93), up.railway.app (78), onrender.com (36), fly.dev (18), and 23 more. 0 of the 28 reached suffixes carries an _x402 record. Frame digest 25e61f5433b1…, and the full sorted host list is in the artifact.

That last figure is the one that matters for §5: a record placed at a shared suffix would be inherited by every tenant beneath it, so the fact that none exists today is what makes the hazard prospective rather than live — and it is the naming rule in §5, not an assumption about who controls the ancestor, that keeps it that way.

The walk is exact; only one line of it is observed. Given the frame and the pinned suffix list, the names visited and the suffix reach are computed, so nothing could have been missed and they are exact for this frame. Whether a suffix carries a record is the single quantity read over the network, and it alone is a bound. Two consequences worth stating plainly:

  • the suffix list is vendored, not fetched. The PSL changes weekly; an unpinned list makes an August number irreproducible in December, invisibly. vendor/README.md carries its source, fetch date and digest.
  • the resolver does not use a suffix list at all, and nothing in src/discovery.ts imports the matcher. §5 refuses that dependency on purpose. Sizing a hazard and depending on the measurement to decide correctness are different acts, and this repo does only the first.

The walk rule itself — the host, then at most two ancestors, never below two labels — is imported from census/discovery-census.ts rather than reimplemented. A measurement about the walk that carried its own copy of the walk would be measuring a second walk that happens to resemble ours, which is how two instruments end up seven hosts apart with nobody able to say why.

npm run walk            # one catalogue read, arithmetic, one DNS query per reached suffix
npm run walk:selftest   # the suffix predicate, both directions, plus the walk bound

Running the census

npm install
npm run verify          # vectors reproduce byte-identically from the generator
npm run census          # both legs
npm run census:dns      # DNS leg only

npm run verify is the anti-drift lock: it regenerates the vectors and diffs them against the committed file. Hand-edit the fixture and it fails.

One DNS query and at most one GET per host, plus a negative control on each leg. No payments, no writes. The Bazaar catalog is the sampling frame, not the population — a host can publish _x402 and never be listed there.

The checks themselves

src/discovery.ts is vendored here so the repo runs with no dependency on anything we publish — a conformance repo you have to trust our registry to run is not much of a conformance repo. It is byte-identical to src/discovery.ts in the package's source repository — and npm run verify-vendor checks that claim against the live canonical file rather than asking you to trust it. (The check exists because the claim silently went stale once, for about two hours, when the canonical file gained new functions after vendoring. A vendored copy rots the moment its source moves; a prose claim does not say so, an executable one does.) The module has zero imports and is the same resolver we run in production rather than a checker written for the occasion.

  • diagnoseX402TxtRecord — conformant / malformed / near-miss / foreign
  • parseX402TxtRecord — the strict gate, unchanged
  • isWkInDomain — the same-origin rule on wk=
  • diagnoseManifest — every violation at once, each marked with whether the discovery extension introduced it or x402 itself did

That last distinction is what produces the 426, and it exists because @melchiorreoliva pointed out on #2979 that a first-throw validator teaches an operator one field per debugging round-trip.

About

Open-path x402 conformance: _x402 TXT grammar, /.well-known/x402 manifest diagnosis, and a live census with negative controls. Offered to x402-foundation/x402#3104.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages