Docs/How it works/Test report
On this page
Test report: from failures to green
Fiangshi decides whether an AI agent should pay a stranger, so it has to be believable when it is wrong as well as when it is right. This report keeps every failure on the record: what we saw, why it happened, what we changed, and the evidence that the change worked. Nothing here has been rewritten after the fact. A lab case written under an old rule stays visible as "superseded", not edited.
The lab history, the per-commit test and gate counts, and the last measured status at the end of this page are computed from committed files every time the page loads. Every other number on this page comes from a committed script or fixture, named next to it.
Where things stand
| measure | value | source |
|---|---|---|
| census | 800 of 1,923 Base payees, 90-day window, taken 9 Nov | scripts/audit.ts, data/numbers.json |
| verdicts | 49 ALLOW / 539 REVIEW / 212 BLOCK | scripts/numbers.ts |
| verdictable payees | 765 (95.6%) | scripts/winrate.ts |
| lab | 9 cases: 8 pass, 1 superseded by a documented rule change | scripts/matrix.ts |
| real sellers in the lab (controls) | 3 of 3 reach ALLOW | artifacts/matrix.md |
| accept gates | 15 of 15, offline, network unplugged | npm run accept |
| end to end | 10 of 10 on a local server, with no keys (fixture mode) and with keys (live) | npm run localhost-check |
| census cost | 252 credits billed for 800 payees | fixtures/audit/balances.json |
| warm check | 251 ms median | artifacts/demo-stats.json |
| cold check | 10.8 s, 8 credits, then 237 ms from cache | artifacts/cold-live.md |
How we test
There are four layers, and each one must be green before a commit.
- Unit and table tests (
npm test, vitest). Pure functions (score, rules, receipts, drift, the probe parser, the markdown renderer, translations) are tested at their edges: 0, the middle, 1, and missing. Each rule has its own table of cases, and a precedence test proves a rule beats a model ALLOW. - Accept gates (
npm run accept). Fifteen checks run with the network blocked by a preload that refuses every connection except the local machine. They are listed in the table below. - End to end (
npm run localhost-check). A real server on a free local port, then ten checks over HTTP: the site, the free check, a BLOCK with a receipt, the paid 402, a paid call with a test payment header, verify, tamper, the index, the badge and the timing. - The adversarial lab (
npx tsx scripts/matrix.ts). Six attack cases and three real sellers run through the live pipeline. The expected answer for each case is committed before the run, and is never edited afterwards.
| gate | what it proves |
|---|---|
| G1 | the code typechecks and builds |
| G2 | every test passes with no network |
| G3 | the probe parses every recorded 402 |
| G4 | the snapshot hash is the same across two builds |
| G5 | an offline check finishes in under 300 ms |
| G6 | a rule beats a model ALLOW |
| G7 | a receipt verifies, and any edit breaks it |
| G8 | a broken model gives REVIEW, marked degraded |
| G9 | the budget guard refuses call N+1 |
| G10 | the index page builds with no script |
| G11 | the recorded real 402s are untouched and all parse |
| G12 | every document agrees with data/numbers.json |
| G13 | a repeated cold check makes zero Surf calls |
| G14 | the kinship thresholds hold |
| G15 | the sensitivity table matches the numbers |
A fifth check runs once per release: a fresh copy of the repository with no keys, then npm ci, npm test, npm run accept and npm run localhost-check. It has passed every time since 9 Nov.
Timeline
| date | phase | what was built | commit |
|---|---|---|---|
| 8 Nov | 0 | skeleton, frozen data contracts (zod), canonical hashing | 4f15a55 |
| 8 Nov | 1–7 offline | probe, snapshot, score, rules, model check, receipts, ledger, drift | 2814c52 |
| 8 Nov | local verification | 10 offline accept gates, the no-network preload | fbbef4b |
| 8 Nov | 3b–3f | Surf installed and measured, 12 real 402s recorded, billing model, E v2, models wired live | 41f235c … c6bdf17 |
| 9 Nov | 3g–3k | paid audit gate, fixture guard (G11), staged census runner, the 800-payee census | 79cd224 … 120f116 |
| 9 Nov | 3l | rule I (null payee), keccak-256 and EIP-55 in the repo, rule set frozen at six | b1a6ffd |
| 9 Nov | 3m–3n | identity v3, adversarial lab with expectations committed first | 3de3cbe … 8290a69 |
| 9 Nov | 3b-fix–3h | optional identity enrichment, API and real 402, end to end check, README, identity neutrality, fixture mode | 218e5fc … f693300 |
| 10 Nov | 3i–3k | the site (no script), operator-only identity labels, guide and legal pages, evidence sources | fbf90f5 … 224ae4c |
| 11 Nov | final A–F | kinship calibration, funder labels re-asked, cold check live, win-rate, single source of numbers (G12–G15) | c9280f3 … 5696cd2 |
| 11–12 Nov | site | guide with real endpoints, test report, crash fix, four languages | 7628f67 … 7cfe713 |
| 13 Nov | connect | architecture page, connect guides, MCP server, free JSON preview, first-visit tour | this release |
The full per-commit list, with the number of test files, test cases and gates at each commit, is in the table near the end of this page.
Every failure, in order
Each entry has the same four parts. Seen is what went wrong. Cause is why. Fix is what changed. Proof is how we know it worked.
F1. Seven real 402s would not parse (8 Nov)
- Seen. 7 of the first 200 live 402 responses from the x402 catalog failed to parse.
- Cause. A seller that offered several payment options was rejected as a whole when any one option was in another format, even when a valid Base option sat next to it. Some sellers also put the terms only in the
payment-requiredheader, not in the body. - Fix. Each payment option is checked on its own; the body is read first, then the x402 v2 header. v1 (
maxAmountRequired) and v2 (amount, CAIP-2 network) are both accepted. - Proof. 5 of the 7 now parse; the other 2 have no Base option at all. 13 recorded real 402s are parsed on every test run (G3, G11), and the raw recordings are never overwritten.
F2. Surf billed more than it reported (8 Nov)
- Seen. The account balance fell faster than
meta.credits_usedsaid. - Cause. Billing is per call by tier. A keyed SQL query costs 4 credits while the reply says 5; a keyed label call costs 4 while the reply says 1. Refused queries and queries killed at the timeout are billed too, with no rows.
- Fix. Spend is measured from the balance before and after, never from the reply. Every call goes through a budget guard. A failed address set is split in half and never sent again. Label calls are capped at 20 addresses, because 50 or more fail with
INTERNAL_ERROR. - Proof. The 800-payee census finished inside its budget; every balance reading is in
fixtures/audit/balances.json. G9 proves the guard refuses call N+1.
F3. The first unattended run wasted credits (9 Nov)
- Seen. One query with 100 payees took 34 s. The next call was refused with
SQL_QUERY_IN_FLIGHT, and 5 credits bought nothing. - Cause. The sync endpoint has a 30 s limit, and a query that runs over keeps running on Surf's side.
- Fix. The runner saves a checkpoint after every query, stops on a 24 s gate and on any refusal without retrying, and uses batches of 50.
- Proof. Every later batch resumed cleanly from its checkpoint, with no batch sent twice.
F4. An async scan was refused for size (9 Nov)
- Seen. A funder query over 300 addresses was refused: about 141 million rows to scan. It was billed 4 credits.
- Cause. The async job has a scan budget, and funder outbound transfers are huge for exchange wallets.
- Fix. The funder pass was split, and the refusal recorded as a billed cost.
- Proof.
artifacts/costs.md, row dated 2026-11-09T02:58:44Z.
F5. Dolphin returned empty answers (9 Nov)
- Seen. The identity extractor came back with nothing.
- Cause. The model spent its whole 200-token allowance on reasoning and was cut off before answering (
finish_reason=length). - Fix. The cap went to 1,024 tokens, and an empty answer fails closed.
- Proof. Identity extraction works when Dolphin answers in time; a truncated answer is stamped
enrichment: incomplete(lab case C1).
F6. The best score belonged to a dead end (9 Nov)
- Seen. The highest score in the census, 80.9 (ALLOW), was an endpoint whose payTo is the zero address. Money sent there is gone.
- Cause. No rule looked at the payment address itself, and the zero address has a long, busy history.
- Fix. A sixth rule, null payee, runs at probe time: zero address, burn address, token contract or failed EIP-55 checksum gives BLOCK before any data or model call, at $0.00, with a sealed receipt. keccak-256 and EIP-55 are written in the repository, with no new dependency.
- Proof.
api.cleave.fi/x402/gasis blocked for $0.00 with zero model calls (lab B5, the guide's live example, the MCP test).
F7. The first lab run passed 3 of 9 (9 Nov)
- Seen. All three real sellers came back REVIEW instead of ALLOW. The burner case (B1) and the sleeper case (B3) never reached the rule they were written for.
- Cause. The slow Dolphin model timed out, and a failed optional step was capping the verdict. A never-funded wallet has no age to judge, so it lands on silence, not age. No real sleeper exists in the census, and the synthetic one has payers, so the history rule cannot fire.
- Fix. Identity extraction became optional: a timeout is stamped on the receipt and never lowers the band. B1 and B3 got alternate expected paths, with the reason written into
cases-v2.jsonand committed before the re-run. - Proof. The second and third lab runs passed 9 of 9.
F8. A rule punished being examined (9 Nov)
- Seen. In the census, 63 sellers whose name was checked but not confirmed had zero ALLOWs, while sellers with no name at all could reach ALLOW.
- Cause. The identity rule gave REVIEW to an unconfirmed claim, and the identity weight scored it below no claim.
- Fix. Identity neutrality: an unconfirmed claim is treated exactly like no claim. The identity rule fires only when a claim is contradicted.
- Proof. Lab case B4 (written under the old rule) now gets REVIEW with no rule fired. It was first recorded as a FAILURE, then marked superseded in
fixtures/lab/supersessions.json. Its expectation was never edited.
F9. A commit went in while a gate was red (9 Nov)
- Seen. Commit
2346072was made while G2 was failing. - Cause. The no-network preload blocked the tests' own local server, and the commit step did not check the exit code.
- Fix. The preload allows only the local machine (127.0.0.1, ::1). Every commit is now gated on the exit code of every gate.
- Proof. Fixed in the next commit,
7721601. No red commit since.
F10. The end-to-end check failed for reasons outside the code (9 Nov)
- Seen. Two failures: a timing check, and a test that behaved differently on the developer machine.
- Cause. The timing check had too little slack under load. Settings from
.env(including keys) were leaking into the tests. - Fix. The timing margin was widened, but kept below the sequential limit so it still catches a serial pipeline. Tests now run without the
.envsettings. - Proof. 10 of 10, repeated in live and fixture mode.
F11. A fresh copy without keys scored 8 of 10 (9 Nov)
- Seen. Someone who clones the repository without model keys could not get a green run.
- Cause. Two checks need a model answer.
- Fix. Fixture mode (
FIANGSHI_REPLAY=1) replays recorded 402s and model replies through the real code, and a test proves it opens no connection except the local machine. - Proof. 10 of 10 from a fresh copy with no keys.
F12. The identity rule's only live BLOCK was wrong (10 Nov)
- Seen. One seller was blocked because its wallet label "contradicted" its product name.
- Cause. The label was a person's handle. A person running a wallet does not contradict a product name.
- Fix. Only a label that names the wallet's operator (an organisation) can contradict a claim. Labels naming a person are never shown on any page.
- Proof. That seller moved from BLOCK to REVIEW;
operatorNameinsrc/core/identity.tshas its own tests.
F13. Receipts misnamed where evidence came from (10 Nov)
- Seen. Every seller name was quoted as a "surf label", even names taken from the seller's own catalog listing.
- Cause. One evidence template was used for every source.
- Fix. Evidence names its real source:
catalog listing name,surf labelorclaimed name. Saved model replies are keyed by the exact evidence they saw, so changed evidence asks the model again instead of failing validation. - Proof. The model now quotes "catalog listing name". The old recording is kept unused in
fixtures/recorded/serv, because raw fixtures are never overwritten.
F14. The first kinship softening went too far (11 Nov)
- Seen. Turning unlabelled big funders from BLOCK to REVIEW at 20 shared sellers moved BLOCK by 33.2%, and would have let the lab's sybil case (B2, which sits at exactly 20) through.
- Cause. The cutoff was one too low.
- Fix. The pass stopped and asked the owner. The cutoff was raised to 21, the smallest that keeps every lab detection. This still moved BLOCK by 25.9%, above the 15% limit first set; the owner accepted it, and it is documented.
- Proof. B2 still blocks. 120 sellers on likely exchange funders are now REVIEW with the flag
kinship_suspected_exchange, and there are 123 kinship BLOCKs.artifacts/sensitivity.mdcompares the three policies (G15).
F15. A headline number was the reported one, not the billed one (11 Nov)
- Seen. The census cost was reported as 189 credits.
- Cause. That is what Surf's replies said; the balance shows 252.
- Fix.
scripts/numbers.tsdefines every headline number from committed data, and gate G12 fails the build if any document disagrees. - Proof. G12 runs on every commit. This page is checked by it too.
F16. A failing request could crash the server (12 Nov)
- Seen. While a page was being served, its image file was deleted, and the server process stopped with
ERR_HTTP_HEADERS_SENT. - Cause. The error handler tried to send a second header after the first had gone out, and the request promise had no catch.
- Fix. The error handler checks whether headers were already sent, and every request is wrapped in a catch.
- Proof. A test sends the failing request and then checks the server still answers.
F17. The report showed a stale status (12 Nov)
- Seen. The report said "263 tests" after the count had grown.
- Cause. The number was typed into the page.
- Fix. The status section is computed from
artifacts/demo-stats.jsonand carries its measurement date ("Last measured status"). - Proof. The section at the end of this page.
F18. The MCP server could not use fetch in tests (13 Nov)
- Seen. Planned at design time: the accept preload denies
fetcheven to the local machine. - Cause. The preload blocks the global fetch to guarantee no outside calls.
- Fix. The MCP server uses
node:httpandnode:httpsdirectly. - Proof.
tests/mcp.test.tsspawns the MCP server as a real process against a test server and runs a full handshake, a tool list and a BLOCK.
Rule changes, each one measured
Every rule change was simulated on the census before it was applied, and its effect was recorded.
| date | change | effect | file |
|---|---|---|---|
| 8 Nov | E v1 (token share) → E v2 (payer concentration) | E stops rewarding a seller for using one token | artifacts/capability.md |
| 9 Nov | rule I added (null payee) | the top-scored dead end is blocked | src/core/v6.ts |
| 9 Nov | identity v3 (corroboration by host, path, catalog or label) | simulated first, in 46c0e86 | src/core/identity.ts |
| 9 Nov | identity optional (timeout never caps) | lab controls reach ALLOW | src/adapters/dolphin.ts |
| 9 Nov | identity neutrality | examined sellers are no longer penalised | src/core/score.ts |
| 10 Nov | operator-only labels | one false BLOCK becomes REVIEW | src/core/identity.ts |
| 11 Nov | kinship v2 (REVIEW at 21+ unlabelled) | 120 BLOCK become REVIEW | artifacts/sensitivity.md |
The rule count has been frozen at six since 9 Nov.
Surf: what we measured about the data source
| fact | measured value | consequence |
|---|---|---|
| billing | per call, 4 credits per keyed SQL call | batch payees, never loop per payee |
| reply vs balance | SQL says 5, labels say 1, both bill 4 | trust the balance |
| sync timeout | 30 s | 24 s gate, batches of 50 |
| killed and refused calls | billed | never re-send a failed set; split it |
| label batch size | fails above about 20 addresses | at most 20 per call |
| async jobs | need an API key | free tier uses two single-branch sync queries |
| window | 365 days at most; census uses 90 | wallet age is a lower bound |
Still failing or not done
These are open, and they are not hidden anywhere else either.
- The four biggest shared funders have no name. Surf was asked twice and has no label for them. If they turn out to be exchanges, up to two thirds of BLOCKs could be wrong. The number is published as an upper bound (
artifacts/winrate.md). - No real payment yet. Settlement is a dry run until the owner pays from their own wallet. Fiangshi will not do it for them.
- 3 real control sellers in the lab, not 10. The census only had a few sellers that pass every rule with complete data.
- The age, history and identity rules have no live case. They are tested on fixtures and tables only.
- Codex, Cursor, VS Code and Gemini CLI setups were not run here. They follow each tool's documented format. Claude Code, Grok CLI and Hermes commands were checked against their
--help. - Cold checks cover Base only. Other chains get REVIEW with
network_not_base.
Tests and gates, commit by commit
Generated by npx tsx scripts/history.ts --write from the local git history. One row per commit. Test cases are it( calls in tests/ (a table-driven case counts once, so vitest runs more). Gates are the distinct G<n> checks in scripts/accept.ts.
| commit | date | test files | test cases | gates | subject |
|---|---|---|---|---|---|
4f15a55 | 2026-11-08 | 1 | 3 | 0 | Phase 0: skeleton, frozen zod contracts, canonical hashing |
2814c52 | 2026-11-08 | 12 | 77 | 0 | Phases 1-7 (offline): probe, snapshot, score, vows, SERV validation, Dolphin cache, receipts + ledger + drift |
fbbef4b | 2026-11-08 | 12 | 77 | 10 | Local verification: npm run accept (10 offline gates), no-network preload, README |
ae58d68 | 2026-11-08 | 12 | 77 | 10 | Docs: receipts are hash-chained, not signed (decision) |
41f235c | 2026-11-08 | 12 | 77 | 10 | phase3b (part 1): surf CLI installed, auth source recorded, schema discovered |
8a47d1f | 2026-11-08 | 14 | 84 | 10 | phase3c (part 1): 12 real x402 v2 402s recorded + parsed, payee-array Surf job runner |
e57bc06 | 2026-11-08 | 15 | 87 | 10 | phase3d: payee facts + billing model |
ff2c711 | 2026-11-08 | 16 | 94 | 10 | audit prep: Bazaar enumerated, 200-payee sample, probes, audit runner + report |
c6bdf17 | 2026-11-08 | 17 | 109 | 10 | phase3f (part 1): E signal v2, multi-chain 402 parser fix, SERV+Dolphin live wiring with fail-closed tests |
9cb2b4e | 2026-11-08 | 17 | 109 | 10 | audit: one funder-outbound call per 300 funders (free-tier 200-payee run = 29 credits) |
79cd224 | 2026-11-09 | 18 | 117 | 11 | phase3g: audit gate + fixture guard |
63f3a2b | 2026-11-09 | 18 | 117 | 11 | phase3h: real SERV + Dolphin smoke on two live payees |
30cf0c6 | 2026-11-09 | 19 | 118 | 11 | phase3i (part 1): staged census runner; gate PASS; stage 1 stopped on async scan-budget rejection |
71b17cd | 2026-11-09 | 20 | 123 | 11 | phase3j (part 1): 100-payee sync census, parallel SERV+Dolphin, pricing v2, provider quirks |
120f116 | 2026-11-09 | 20 | 127 | 11 | phase3k: uniform 90d audit (800 payees) + audit.md |
b1a6ffd | 2026-11-09 | 22 | 154 | 11 | phase3l: V6 + cluster resolution + latency |
46c0e86 | 2026-11-09 | 22 | 154 | 11 | phase3m (pre): simulate identity v3 on the census before changing the rubric |
3de3cbe | 2026-11-09 | 23 | 164 | 11 | phase3m: scoring v3 (identity corroboration) |
0587fe1 | 2026-11-09 | 23 | 164 | 11 | lab: expected verdicts written before running (9 cases) |
dc8f7ad | 2026-11-09 | 23 | 164 | 11 | phase3n: adversarial lab (9 cases, expectations committed first) |
8290a69 | 2026-11-09 | 23 | 164 | 11 | lab v2: re-classified expectations written before the re-run (B1/B3 alternate paths, reasons recorded) |
218e5fc | 2026-11-09 | 23 | 165 | 11 | phase3b-fix: optional enrichment + V5 age |
0172628 | 2026-11-09 | 23 | 167 | 11 | phase3c: live index (census state from committed fixtures) + Dolphin negative cache |
2346072 | 2026-11-09 | 24 | 175 | 11 | phase3d: API surface + real x402 v2 402 (dry-run settlement) |
7721601 | 2026-11-09 | 24 | 175 | 11 | phase3d-fix: accept G2 green, loopback allowed under no-network |
4ad3624 | 2026-11-09 | 24 | 175 | 11 | phase3e: localhost end-to-end check (10/10 PASS) |
e3a9889 | 2026-11-09 | 24 | 175 | 11 | phase3f: README with current numbers, LICENSE (MIT), .nvmrc, complete .env.example |
206c2ec | 2026-11-09 | 24 | 175 | 11 | phase3g: wrap — localhost-check forces live mode; fresh-clone verified |
f693300 | 2026-11-09 | 25 | 180 | 11 | phase3h: identity neutrality + fixture mode |
fbf90f5 | 2026-11-10 | 25 | 185 | 11 | phase3i: frontend (index, check and receipt pages; static, no JS) |
21060ab | 2026-11-10 | 26 | 204 | 11 | phase3j: operator-only identity labels; guide and legal pages |
224ae4c | 2026-11-10 | 26 | 206 | 11 | phase3k: identity evidence names its real source |
c9280f3 | 2026-11-11 | 26 | 206 | 11 | final-a (halted at A5): kinship sensitivity measured, calibration not applied |
bac98bc | 2026-11-11 | 26 | 208 | 11 | final-a: sensitivity + calibrated kinship (threshold v2) |
0a2296c | 2026-11-11 | 26 | 208 | 11 | final-b: cluster labels resolved (all 19 funders unnamed) |
7007d50 | 2026-11-11 | 27 | 215 | 11 | final-c: cold check live |
ba3cbe8 | 2026-11-11 | 27 | 215 | 11 | final-d: win-rate |
b309cce | 2026-11-11 | 27 | 215 | 15 | final-e: single source of numbers |
74a799e | 2026-11-11 | 27 | 215 | 15 | final-f (1/2): live localhost-check refreshed demo-stats (warm p50 251 ms) |
5696cd2 | 2026-11-11 | 27 | 215 | 15 | final-f: report + proof |
7628f67 | 2026-11-11 | 27 | 216 | 15 | guardian: dragon and poster hero (CSS only) |
f55e212 | 2026-11-11 | 27 | 216 | 15 | guardian: keep the poster, remove the dragon |
00b722b | 2026-11-12 | 28 | 224 | 15 | site: clock mark back, English wordmark, guide with live examples, test report |
941d26c | 2026-11-12 | 28 | 225 | 15 | server: a failing request can no longer crash the server |
4cc40bf | 2026-11-12 | 29 | 235 | 15 | site: Chinese name in the clock mark, four-language switcher |
7cfe713 | 2026-11-12 | 29 | 235 | 15 | site: remove the clock mark |
b511c89 | 2026-11-13 | 31 | 253 | 15 | connect: MCP server, /v1/preview, architecture and connect pages, deep report, first-visit tour |
51da9b2 | 2026-11-13 | 31 | 254 | 15 | site: plain tour button, animated working models on /architecture |
34076bd | 2026-11-13 | 31 | 254 | 15 | architecture: animated live map replaces the text-art map |
b19fe5c | 2026-11-13 | 32 | 263 | 15 | docs hub, agent CLI and skill; tour button removed |
Lab runs, as committed
Each run of the adversarial lab: 6 attack cases and 3 real sellers, each with an expected answer written before the run. Read from fixtures/lab/ when this page loads.
| run | when | passed | failed | superseded | outcome | file |
|---|---|---|---|---|---|---|
| 1 | 2026-11-09 03:52 UTC | 3 / 9 | B1, B3, B4, C1, C2, C3 | none | FAIL | results.json |
| 2 | 2026-11-09 07:32 UTC | 9 / 9 | none | none | PASS | results-attempt2.json |
| 3 | 2026-11-09 07:35 UTC | 9 / 9 | none | none | PASS | results-attempt3.json |
| 4 | 2026-11-09 08:19 UTC | 8 / 9 | none | B4 | SUPERSEDED | results-attempt4.json |
| 5 | 2026-11-11 02:30 UTC | 8 / 9 | none | B4 | SUPERSEDED | results-attempt5.json |
Last measured status
Last full measurement, 2026-11-11, by npm run localhost-check -- --live (it needs the model keys). Re-run it to refresh these numbers.
- 263 tests passing (
npm test). - Accept gates 15/15, offline, with the network unplugged (
npm run accept). - End to end 10 / 10, warm check 251 ms.
- A fresh copy with no keys passes all of these in fixture mode.