Skip to content

Latest commit

 

History

39 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

evidence-record-conformance

verify

Conformance vectors for the evidence-record layer of agent commerce — the layer whose properties must survive the record being held by an interested party.

Record shapes and signatures (maturing in several parallel efforts) answer "is this record intact, and what does it name?" The evidence-record layer sits above them and answers three questions no record shape or signature supplies by structure alone:

  • Independence — a record attested only by parties to the transaction evidences structure, not independence. An evaluator MUST NOT treat issuer-attested composition as a neutral finding. (independence_reject)
  • Completeness — an issuer-attested sequence evidences ordering, not no-omission. Without a committed head, a truncated sequence is invisible; without an independently verifiable existence bound, the head itself is just another claim. (completeness_reject, existence_reject)
  • Phase separation — a record evidencing one economic phase (funding) MUST NOT verify as evidence of a later one (delivery, settlement). (phase_reject)

All three are proposed normatively in the open compliance-fields extension PR (x402-foundation/x402#2853, under review); this suite makes them executable.

Externally exercised. The corpus has been executed and attacked from outside this repository, on the record: byte-identical reproduction from the IETF web-bot-auth list (Songbo Bu, 2026-08-10 — verifier, TypeScript cross-check, 258-case differential and byte-identical regeneration, against 46ad663); a published mutation-adequacy re-grade by @Rul1an (2026-08-25, at 0e560c1: 11 declared mutants killed, 0 survived, 1 declared equivalent); the anchor-preimage relation recomputed from a separate implementation (#3); and four vector classes contributed by external authors (CONTRIBUTORS.md).

Run

python3 verify.py

stdlib-only, no dependencies, no network. Exit 0 only if every vector produces its expected verdict and the run observed both verdicts and the pinned closure of 10 reject reasons and 11 vector kinds was fully exercised, and every kind produced both verdicts — the closure is pinned in the verifier, not derived from the manifest, so a fork that quietly drops a class goes red. A green run demonstrates the verifier discriminates, not merely accepts.

Vector classes

class accepts rejects reason code
content address (keccak256 over RFC 8785) p1 (live), p3 n1 value drift recompute_mismatch
canonical bytes p2 n2 hoisted integer keys, n12 code-point key order canonicalization_reject
number domain (I-JSON integers) p12 (2^53−1 boundary), p13 (decimal string beside integer — spec-lockstep) n10 float, n11 integer past 2^53−1 number_domain_reject
number token in the digest domain p25 (integer token via raw payload_text) n35 integer-valued float token (wire bytes 2.0JSON.parse collapses it to 2, the cross-engine divergence the pair closes) number_domain_reject
supplementary-plane key order (UTF-16 vs code point) p14 n12 canonicalization_reject
chain link (artifact ∥ prev ∥ seq) p4 n3 wrong predecessor continuity_reject
per-seller set continuity + completeness p6 (with per-record links) n4 silently omitted record, n17 renumbered omission (stale link) completeness_reject, continuity_reject
witnessed inclusion vs completeness p24 (witnessed complete set) n34 witnessed inclusion is not completeness completeness_reject
chain commitment (accumulator over counter-signed links) p26, p27 (live) n36 substituted prefix, n37 last-link-only accumulator continuity_reject
anchored existence bound p5 (live) n5 truncated/substituted head existence_reject
economic-phase separation p7 n6 funding-as-delivery, n18 unrecognized phase phase_reject
offer binding (receipt commits to the accepted offer's canonical digest) p15 n19 offer substitution (same resource/network, different amount/payTo) binding_reject
decision-evidence binding (protected record commits to the canonical authority reduction) p19 n27 unbound reduction, n28 reduction substitution binding_reject
boundary binding p18 (binds prefix and position), p20 (suite transition preserves the prefix under the suite in force when written) n25 fabricated boundary (prefix-only binding), n26 downgrade (coverage over an empty attestation), n29 retroactive re-digest (transition binds the successor-suite digest of the same bytes — the engine that re-hashes history agrees with it, so it discriminates) boundary_reject
independence criterion p8, p9 (no claim), p10 (claim set), p11 (set, silence only), p16 (scope ⊆ derived commitments), p17 (derived commitments, resolvable settlement), p21 (URN identities — the criterion decides under a second identity syntax), p22 (delivery commitment, recomputed digest, claim silent), p23 (delivery independence within commitment — the vector that drives the delivery derivation; no settlement present) n7 issuer-only attestation, n8 unrecognized claim, n9 unread member in a set, n13 party alias (whitespace), n14 unparseable attestor, n15 claim w/o attestations, n16 non-object attestation, n20 scope past commitment, n21 empty settlement (derived commitments), n22 declared override (list beside derivable result), n23 explicit-null declaration (the engine-fork input), n24 declared with no scope asserted (the presence rule's second half), n30 URN self-attested (rejects for the independence reason, not the identifier), n31 URN alias (trailing slash — n13 one syntax over; percent-encoding stays a stated open sibling), n32 delivery self-attested (same record as p22, independence claimed over it — the deliverer's own signature is the only attestation), n33 delivery substitution (p23's bytes tampered, digest as issued — commits to no delivery, the claim overreaches; rejects on the scope branch, not the unevaluable one) independence_reject

Two design rules, both enforced by the run itself:

  1. At least one adversarial vector per known failure class — an all-happy-path suite proves nothing about the class it never exercises. n2 encodes a failure observed in a real implementation (JS engines hoist integer-like keys into numeric order on object rebuild, silently defeating sort-then-stringify; RFC 8785 orders "1" < "10" < "2" by UTF-16 code units); n12 is the same rule where it bites hardest — a supplementary-plane key sorts FIRST by UTF-16 code units and LAST by code point. n4 is the completeness class this layer exists for, and n17 is its harder sibling: omission hidden by renumbering, visible only because the relabeled record carries the link computed for its original position.
  2. Every criterion is two-sided — each class has an accepting twin, so an implementation that unconditionally rejects a class fails the suite just as one that unconditionally accepts it does (p7/p8 exist for exactly this). Enforced per kind since 2026-08-19, not per run: until then the gate checked {valid, reject} over the whole run, so a criterion whose accepting vectors all disappeared stayed green as long as some other criterion contributed a valid somewhere — the rule was real in this paragraph and absent from the gate. Reported by @Rul1an (#1), who also traced what it had already let through: d50545a moved the independence criterion from deciding to not-deciding under URN identities, and the run said nothing. It is also why p21/n30 exist — one accepting vector per identity syntax the manifest says the criterion evaluates, so that trade goes red at the commit.

A third rule, added after this suite failed it: a criterion's trigger must fail closed. An exact-equality trigger (if claimed != "independent": valid) reads any unfamiliar claim string — including a stronger one — as no claim at all, switching the check off precisely where more was asserted. Silence is a valid state (p9); an assertion the verifier cannot interpret is not (n8). Failing closed also means returning a verdict for every shape: a claim with no attestations (n15), an attestation that is not an object (n16), or an attestor identifier that does not parse as an address (n14) each produce a reject, where earlier implementations raised and produced no verdict at all — including the set form (p10), which is where a field carrying two orthogonal criteria has to land. Reported against this suite by @Rul1an.

A fourth criterion property, from the extension's commitment-scope rule (first proposed in x402-foundation/x402#2887, 2026-07-27): an independence claim reaches exactly as far as the record's commitments. However independent the attestor, the attestation covered the committed bytes and nothing else — a claim covering an uncommitted fact class rejects (n20), and a scoped claim within the commitments accepts (p16). This is a rule its authors fail-safe on deliberately: a record with no delivered-bytes commitment carries no delivery independence, whoever counter-signed it. Three fact classes are derivable (MANIFEST.jsoncommitment_derivation): settlement and network off a settlement result, and — since v0.4.0 — delivery off the record's own bytes, when keccak256(utf8(deliverable_bytes)) recomputes to deliverable_digest (p23 accepts a delivery-scoped claim with a non-party attestor; n33 substitutes one byte of the deliverable and the claim overreaches). Until then no record could commit to delivery at all, so n20 rejected for a structural reason that happened to be correct for the wrong cause — enumerated by @0rkz, whose PayPerByte fixture (p22/n32) anticipated the derivation and does not move under it. Field naming follows the manifest's field_naming rule: harness keys snake_case, protocol-quoted keys keep the protocol's spelling.

A fifth property, and the reason the fourth cannot be satisfied by assertion: a record's commitments are DERIVED from the record, never declared alongside it — and a declared commitment scope is itself a rejectable input, whatever it holds. A declared commitment list lets a record assert the very scope the commitment-scope rule exists to bound, which makes the rule vacuous exactly where it matters. The concrete case: x402 v2 §5.3.2 defines the empty string as what transaction carries when settlement failed, while the type only requires a string — so success: true with transaction: "" is well formed and commits to no settlement anyone can resolve. Deriving commitments off the settlement result rejects an independence claim over that record (n21) without resolving anything on-chain, and accepts the same claim when the result carries a transaction reference that resolves (p17). The property is enforced on the field's presence: a record carrying record_commits beside a derivable result rejects even when the list matches the claim (n22 — the override that made the first, fallback-shaped fix decorative), and an explicit null rejects identically in both implementations (n23 — the input on which a value-sentinel guard forked the two engines; both now guard on key presence, whose semantics are identical in Python and JS). Reported against this suite — twice, the second time against the first fix — by @Rul1an.

A sixth, added by the same review discipline: identity comparison runs after normalization. EIP-55 mixed case and stray whitespace are the same address; without normalization, a party relabels itself as its own "outside" witness by appending a space to its own address (n13) — an alias bypass of the independence criterion, failing open exactly where the criterion exists to fail closed. An identifier that does not parse after normalization is not evaluable and rejects (n14).

A seventh property applies the same binding arithmetic to a different semantic object: a protected record presented as evidence of an authority decision must commit to the exact decision-evidence object it names. Without that commitment, the presented reduction is unbound and rejects (n27). n27 deliberately exercises only this missing-commitment branch; the distinct-object contrast is load-bearing across p19/n28: a commitment to reduction A accepts A (p19) and rejects B (n28). This structural criterion does not validate the authority intersection, authenticate the producer or establish historical position; it only makes a missing commitment and substitution detectable.

This suite instantiates the relation with its local RFC-8785-compatible canonicalizer and Keccak-256. The conformance property is the algorithm-parametric relation “matching canonical object accepts; missing or mismatching commitment rejects”, not a prescription of a digest, canonicalization, or field location for AUEC, MCP, or another protocol.

Number-token class on integer fields (v0.5.1)

JSON.parse collapses the wire token 3.0 to the integer 3; Python's json keeps a float, and every integer predicate in verify.py rejects it. Until v0.5.1 the two engines therefore diverged on the same bytes for a fractional or exponent seq token — a real parity fork, found by an adversarial review of v0.5.0. The suite's number-domain boundary has always been the TOKEN class (p25/n35 pin it for canonical bytes); n38 pins the same boundary for sequence numbers, and the TypeScript engine now loads every vector and differential case through a reviver that reads integer fields at the token level (Node ≥ 21 source access), so both engines reject 3.0 / 3e0 wherever an integer is required. Additive: every pre-0.5.1 vector is byte-identical; the differential battery gained float-token mutations on head.seq and records[].seq for both chain kinds.

Commitment accumulator (v0.5.0)

A committed head binds the last record only. Two prefixes that end in the same record — the real one, and one whose earlier rows were substituted with prevs and links recomputed — both pass the chain_set predicate and both sit under the same anchored head digest; the head cannot tell them apart, and an anchor over the head therefore commits to the final record, not to the prefix beneath it. The chain_commitment kind pins the construction that closes this: an accumulator folded over every recomputed link,

acc_0 = keccak256(utf8("tersign-chain-commitment-v1"))
acc_n = keccak256(acc_{n-1} || link_n)        link_n = keccak256(artifact_n || prev_n || seq_be8)

with prev_n the previous record's artifact digest (32 zero bytes at seq 1). head.acc must equal acc_{head.seq}; any omission, insertion, reordering or rewrite below the head changes it. The seed is a tagged digest, never the 32-zero-byte link-genesis sentinel — link preimages are 72 raw bytes, accumulator preimages 64, every canonical-object digest is UTF-8 text starting {, so the three domains cannot collide by construction. Production anchors keccak256(utf8(canonical({acc, head, schema: "tersign-chain-commitment-v1", seq}))) rather than the head digest, so one anchored value commits the whole prefix (p27 is that live chain; its anchor row carries the subject object). n36 is the substituted prefix presented with the real chain's accumulator; n37 is an accumulator folded over the last link only — the exact value an anchor over the head commits to, and the shape this kind exists to reject.

The kind is additive: every pre-0.5.0 vector is byte-identical, chain_set's semantics are unchanged (chain_commitment runs it first and propagates any non-valid verdict as-is), and the external reproductions at 46ad663 (Songbo Bu) and 0e560c1 (@Rul1an) stand as published. Verification is O(N) in the number of records — one link and one accumulator step per record, hashing only — which is the cost of a completeness check that does not delegate to anyone's word; the anchor over the commitment is what turns the fold into an existence bound (anchor_relation over the commitment digest).

Scope boundary — structural profile vs crypto profile

This stdlib core decides the structural predicate: digests, canonical bytes, sequence closure, link arithmetic, declared-claim evaluation. It does not recover counter-signatures. A structurally complete set whose head and links were all recomputed wholesale by a single forging party passes the structural predicate — what prevents that in production is that every chain link is counter-signed at transaction time by a party outside the transaction, and the chain's commitment is anchored (p5, p27). Signature recovery over the links (secp256k1 personal_sign; signer published at https://tersign.ai/v1/ledger) is the crypto profile, the suite's next milestone — deliberately outside the stdlib core so that every check above needs hashing only.

Live provenance — three vectors are records from the live ledger

p1 is the tersign ledger's genesis (demo) receipt — the one receipt whose full body is public by design. Re-fetch the bytes and recompute the digest yourself:

curl https://tersign.ai/v1/genesis        # the record body — same bytes as the vector
python3 verify.py                         # recomputes the digest from the committed bytes
curl https://tersign.ai/v1/receipts/0xe5874f1ffe87f0a6dd9eb157730f67b86ee4538b125fe30fcc4e165213dd3fc4/verify

(The payload's embedded resourceUrl is the historical demo resource the genesis record was issued against, on the ledger's legacy workers.dev alias — the record's validity derives from digest, counter-signature and anchor, never from URL liveness.)

p5 is a counter-signed chain head anchored in Bitcoin block 958163. The existence bound is checkable without trusting the operator:

curl https://tersign.ai/v1/anchors/ledger:0xb2c5d2bd28ff65e13c1549a718a4c447916d5277ce046b2061ed63749ff287d9
                                          # the anchor record BY ID; fetch proof.ots from its proofUrl
ots verify -d cf48bed1712f5b7df2a309fb52cb2b3d51ab1a04730e3b115cd3db79c96c9b1a proof.ots

(Fetch it by id: the unqualified /v1/anchors listing returns the newest rows and will eventually not include this one.) ots verify needs a local Bitcoin node to confirm the block header. Without one, the no-node path is stronger anyway — it shows the trust chain explicitly: ots info proof.ots prints the Bitcoin attestation (height 958163, merkle root d23b2da439b5…f85df18c); compare that root against block 958163 in any block explorer.

p27 is the ledger's genesis chain — 13 counter-signed records, seq 1 being the genesis receipt p1 — with the accumulator over all 13 links, and the commitment over it is anchored in Bitcoin block 964428. Re-walk and re-fold it yourself, then check the anchor:

curl https://tersign.ai/v1/receipts/0x339800528596c7d53d32571ad999695aef6dfc8fc86dcc4fb827bb6080493961/verify
                                          # the head; follow prevDigest 12 more times to seq 1 (prevDigest null)
python3 verify.py                         # folds the accumulator over the 13 committed records
curl https://tersign.ai/v1/anchors/seller:0xcbbef04598368ed02ae67fc0c8ffade6753628d0b8faf4e9211c9dd49a2dbe7b
                                          # subjectArtifact = {acc, head, schema, seq}; merklePath to batchRoot; proofUrl

The anchor's subjectDigest is keccak256(utf8(canonical(subjectArtifact))) and its anchoredDigest is sha256 of those bytes (the same anchor_relation p5 pins); the vector's provenance block carries both values.

Counter-signatures in the live vectors are secp256k1 personal_sign material; recovering them requires an EVM crypto library and sits outside the stdlib core by design — every check above needs hashing only.

Pin the signer, do not fetch it. The ledger signer at genesis is 0x9d38BA84730271eb27Ac9bD4Bd2620c08dB4FDa6, committed in this repository since p1-live-genesis-receipt.json (field ledger_signer) and reproduced byte-identically by tools/gen_vectors.py on every CI run. https://tersign.ai/v1/ledger serves the same value, but a key fetched at verification time only proves what the server says now — "verify offline" has to mean against a key committed at a fixed point in time, which is what the vector gives you. The genesis receipt digest is likewise fixed (0xe5874f1ffe87f0a6dd9eb157730f67b86ee4538b125fe30fcc4e165213dd3fc4) and its chain head is Bitcoin-anchored, so the pinned pair is recoverable from an anchored record rather than from an endpoint. Any future signer rotation must be published as a new pinned vector, never as a silent change at that URL.

Canonicalization contract

RFC 8785 (JCS) over the I-JSON vector domain: integer numerics within |n| ≤ 2^53−1 (enforced two-sidedly — p12 accepts the boundary value, n11 rejects one past it), no non-integer JSON numbers (n10 — RFC 8785 §3.2.2.3 routes numbers through ECMAScript Number::toString over IEEE 754 doubles, so a fractional value's bytes depend on the producer's number pipeline, and the digest binds the nearest double rather than the decimal the source system held; fractional values are decimal strings, pinned in lockstep with the compliance-fields extension's number rule by p13), duplicate object names rejected at load. Keys sort by UTF-16 code units (the verifier encodes to UTF-16BE and compares bytes — explicit, not delegated to the host language's default; p14/n12 pin the supplementary-plane case where code-unit and code-point order genuinely diverge). Content addresses are keccak256 (pre-NIST padding, as used by Ethereum) — hashlib.sha3_256 is a different function; a compact Keccak implementation is vendored in keccak.py, self-checked at import against measured known-answer values.

Cross-implementation measurement: tools/cross_check_ts.mjs is an independent TypeScript-stack implementation of every check, run over the full committed corpus (npm i viem in the repo root, then node tools/cross_check_ts.mjs) — two implementations, one vector set, byte-level agreement required on every verdict and reason. CI runs both on every push.

That corpus runner scores each engine against MANIFEST.json, so engine-to-engine agreement there is transitive through the shared expectations — both implementations can hold the same wrong assumption and stay green, and an input the corpus does not carry is never compared at all. tools/differential.py is the non-transitive control: every vector plus an off-corpus mutation battery at fork-prone keys (explicit nulls, declared/derivable conflicts, containers of the wrong shape), run through both engines directly, verdict and reason compared with no manifest in between. Replayed against the pre-fix engines it reports exactly the null-guard fork it was built after; on current code it must report zero divergences. CI runs it beside the corpus pass. The limit it closes was identified by @Rul1an: parity through a shared oracle confirms a shared assumption instead of catching it.

Regeneration is deterministic and diffable: python3 tools/gen_vectors.py rewrites vectors/ + MANIFEST.json byte-identically (CI asserts this on every push).

Contributors

Who sharpened which criterion, and how each contribution landed, is recorded in CONTRIBUTORS.md — credited by commit authorship rather than by a merge badge, since some contributions were cherry-picked onto a hardened main and their PRs therefore read as closed.

License

Apache-2.0. Maintained by Tersign. Cross-runs, counter-vectors, and adversarial additions welcome.

About

Two-sided conformance vectors for the evidence-record layer — independence, completeness, anchored existence, phase separation, offer/decision/boundary binding. stdlib-only verifier + independent TypeScript cross-check; live Bitcoin-anchored vectors; externally reproduced byte-identically.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages