agent-receipt-verify 0.2.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/README.md +38 -3
  2. package/bin/cli.mjs +41 -24
  3. package/package.json +5 -3
  4. package/src/acceptance-binding.mjs +309 -0
  5. package/src/settlement-link-vectors.mjs +124 -11
  6. package/src/verify-delivery.mjs +507 -0
  7. package/src/x402-conformance-check.mjs +41 -5
  8. package/src/x402-discovery-census.mjs +24 -1
  9. package/src/x402-offer-receipt.mjs +49 -3
  10. package/tools/naar-kanon-vorm.mjs +172 -0
  11. package/tools/pack-inhoud.mjs +45 -1
  12. package/vectors/acceptance-binding/crossed-deliverable.json +40 -0
  13. package/vectors/acceptance-binding/pairs-same-deliverable.json +39 -0
  14. package/vectors/acceptance-binding/real-record-as-issued.json +68 -0
  15. package/vectors/acceptance-binding/real-record-crossed-deliverable.json +65 -0
  16. package/vectors/acceptance-binding/real-record-crossed-x402-keys.json +74 -0
  17. package/vectors/acceptance-binding/real-record-with-producer-key.json +66 -0
  18. package/vectors/acceptance-binding/real-record-x402-keys-match.json +76 -0
  19. package/vectors/settlement-link-kanon/README.md +69 -0
  20. package/vectors/settlement-link-kanon/find-candidates-onchain.json +43 -0
  21. package/vectors/settlement-link-kanon/no-candidates-not-measured.json +42 -0
  22. package/vectors/settlement-link-kanon/opens-live-2.json +47 -0
  23. package/vectors/settlement-link-kanon/opens-live-3.json +47 -0
  24. package/vectors/settlement-link-kanon/opens-live.json +47 -0
  25. package/vectors/settlement-link-kanon/two-identical-payments.json +76 -0
  26. package/vectors/settlement-link-kanon/wrong-nonce-live-2.json +46 -0
  27. package/vectors/settlement-link-kanon/wrong-nonce-live-3.json +46 -0
  28. package/vectors/settlement-link-kanon/wrong-nonce-live.json +46 -0
  29. package/vectors/settlement-link-kanon/wrong-salt-live-2.json +46 -0
  30. package/vectors/settlement-link-kanon/wrong-salt-live-3.json +46 -0
  31. package/vectors/settlement-link-kanon/wrong-salt-live.json +46 -0
package/README.md CHANGED
@@ -77,11 +77,23 @@ vectors, that is interop. One implementation agreeing with itself proves nothing
77
77
  | x402 settlement link (§5.6.4, same proposal) | own tests plus three live settlements | 22 offline, 3 live | yes, each resolves to its own settlement |
78
78
 
79
79
  ```sh
80
- npm test # all of the above, no network, no keys
80
+ npm test # all of the above. No account, no key, and nobody else's server
81
81
  ```
82
82
 
83
- Node built-ins only, apart from keccak256. Only the settlement link below touches
84
- the network, and then only a public read-only RPC.
83
+ Node built-ins only, apart from keccak256. Two honest footnotes, because "no network"
84
+ was not quite true and is the kind of claim this package should not make loosely:
85
+
86
+ - One case dials a closed port on localhost on purpose, to prove that a dead address
87
+ reports not-measured instead of crashing the tool. That is a socket, but it leaves
88
+ the machine alone and reaches no third party.
89
+ - Until recently the suite also fetched a live discovery index belonging to someone
90
+ else, roughly twenty thousand entries, because one test handed an unknown flag to
91
+ `census` and `census` ignored unknown flags and went to work. That flag is now
92
+ refused, so the suite finishes in about eleven seconds and contacts nobody. A single
93
+ index round alone used to take over thirty.
94
+
95
+ Only the settlement link below reaches a real network, and then only a public,
96
+ read-only RPC.
85
97
 
86
98
  Confirmed from the other side, which matters more than the table: the author of
87
99
  the x402 specification ran their own implementation, which shares no code with
@@ -98,6 +110,7 @@ taken on trust:
98
110
  ```sh
99
111
  npx -y agent-receipt-verify kanon
100
112
  npx -y agent-receipt-verify beleid
113
+ npx -y agent-receipt-verify link-vectors
101
114
  ```
102
115
 
103
116
  The first checks our EIP-3009 verifier against the ten published vectors of
@@ -107,6 +120,28 @@ about one nonce reused across two payers. Those are the two open questions in
107
120
  [x402#2725](https://github.com/x402-foundation/x402/issues/2725), and the command
108
121
  prints exactly where the two possible answers diverge and where they do not.
109
122
 
123
+ The third runs twelve vectors for the half AFTER paying: does this receipt belong to
124
+ that one settlement. Eleven of them need no network at all; the twelfth reads the chain
125
+ and is marked and skipped rather than reported as a failure, because a check that could
126
+ not run says nothing about the receipt. The same twelve also exist in the field layout
127
+ of the Kanon vectors, in `vectors/settlement-link-kanon`, so the two corpora can sit
128
+ side by side.
129
+
130
+ Two more commands, both of which measure other people's endpoints rather than our own,
131
+ and both read-only:
132
+
133
+ ```sh
134
+ npx -y agent-receipt-verify census [out.json]
135
+ npx -y agent-receipt-verify liveness [out.json]
136
+ ```
137
+
138
+ `census` counts a discovery index per HOST rather than per resource, so a party with
139
+ three hundred paths does not outweigh three hundred parties with one. `liveness` takes
140
+ that census and asks each host whether it still answers 402 today, because a
141
+ registration in an index is not a promise that the endpoint is alive. Both are slow by
142
+ design: they pause between requests, because the hosts in that list did not ask to be
143
+ measured.
144
+
110
145
  ## Linking a receipt to its settlement
111
146
 
112
147
  A receipt with an empty `transaction` field can be matched to a payment only by
package/bin/cli.mjs CHANGED
@@ -9,7 +9,7 @@
9
9
  // BEWUST GEEN NPM-PUBLICATIE NODIG. npx kan een pakket rechtstreeks uit een git-repo
10
10
  // draaien, dus dit werkt zonder dat er iets op npm staat:
11
11
  //
12
- // npx github:holistis/agent-receipt-verify https://een-eindpunt.example/pad
12
+ // npx -y agent-receipt-verify https://een-eindpunt.example/pad
13
13
  //
14
14
  // Dat is geen trucje maar een echte eigenschap van npx, en het haalt een hele
15
15
  // publicatie-stap plus een naamclaim weg. Het betekent ook dat niemand een
@@ -23,45 +23,61 @@
23
23
  const args = process.argv.slice(2)
24
24
  const eerste = args[0]
25
25
 
26
+ // De helptekst is ENGELS, en dat is geen voorkeur maar een gemeten noodzaak. Koud
27
+ // gedraaid vanuit een lege map op 18 oktober 2026 bleek het eerste scherm dat een vreemde
28
+ // ziet half Nederlands, terwijl iedereen die dit gereedschap in de x402- en
29
+ // erc-8004-draden gaat draaien Engels schrijft. Het meetresultaat zelf was al Engels, dus
30
+ // alleen de ingang stond nog in de weg. En de regel over wat dit NIET meet stond midden
31
+ // in de lijst in plaats van eronder, waar een lezer hem pas leest nadat hij weet wat het
32
+ // wel doet.
26
33
  function gebruik() {
27
- console.log('agent-receipt-verify, een onafhankelijke controle op bonnetjes van agent-betalingen')
34
+ console.log('agent-receipt-verify, an independent check on receipts for agent payments')
28
35
  console.log('')
29
- console.log(' npx github:holistis/agent-receipt-verify <url> [url ...]')
30
- console.log(' meet een x402-eindpunt tegen de ladder in CONFORMANCE.md (trede 0 tot 2,')
31
- console.log(' zonder te betalen). Trede 3 tot 5 vereisen een echte afrekening en komen')
32
- console.log(' terug als ONGEMETEN, nooit als afgekeurd.')
36
+ console.log(' npx -y agent-receipt-verify <url> [url ...]')
37
+ console.log(' measures an x402 endpoint against the ladder in CONFORMANCE.md (levels 0')
38
+ console.log(' to 2, without paying). Levels 3 to 5 need a real settlement and come back')
39
+ console.log(' as NOT MEASURED, never as a failure.')
33
40
  console.log('')
34
- console.log(' npx github:holistis/agent-receipt-verify census [uit.json]')
35
- console.log(' telt een discovery-index per host in plaats van per resource.')
41
+ console.log(' npx -y agent-receipt-verify census [out.json]')
42
+ console.log(' counts a discovery index per host instead of per resource.')
36
43
  console.log('')
37
- console.log(' npx github:holistis/agent-receipt-verify link --receipt=<bestand>')
38
- console.log(' zoekt bij een bonnetje van versie 2 de bijbehorende afrekening op de keten.')
44
+ console.log(' npx -y agent-receipt-verify link --receipt=<file>')
45
+ console.log(' for a version 2 receipt, finds the settlement it belongs to on chain.')
39
46
  console.log('')
40
- console.log(' npx github:holistis/agent-receipt-verify mcp')
41
- console.log(' draait als MCP-server over stdio, zodat een agent deze controle AANROEPT')
42
- console.log(' in plaats van hem aanbevolen te krijgen. Twee gereedschappen: controleer een')
43
- console.log(' eindpunt voor je betaalt, en link een bonnetje aan een echte afrekening.')
47
+ console.log(' npx -y agent-receipt-verify mcp')
48
+ console.log(' runs as an MCP server over stdio, so an agent CALLS this check instead of')
49
+ console.log(' being advised to run it. Two tools: check an endpoint before you pay, and')
50
+ console.log(' link a receipt to a real settlement.')
44
51
  console.log('')
45
- console.log(' npx github:holistis/agent-receipt-verify inam <vectors.json>')
46
- console.log(' controleert countersigned bonnetjes tegen gepubliceerde vectoren.')
52
+ console.log(' npx -y agent-receipt-verify inam <vectors.json>')
53
+ console.log(' checks countersigned receipts against published vectors.')
47
54
  console.log('')
48
- console.log('Wat dit NIET doet: het oordeelt over de VORM van een bonnetje, niet over de')
49
- console.log('eerlijkheid van de partij die het afgaf. Zie CONFORMANCE.md voor wat met opzet')
50
- console.log('niet gemeten wordt.')
51
- console.log('')
52
- console.log(' npx github:holistis/agent-receipt-verify kanon')
55
+ console.log(' npx -y agent-receipt-verify kanon')
53
56
  console.log(' cross-check our independent EIP-3009 verifier against the ten published')
54
57
  console.log(' vectors of iamonuwa/kanon, offline, no account and no key.')
55
58
  console.log('')
56
- console.log(' npx github:holistis/agent-receipt-verify beleid')
59
+ console.log(' npx -y agent-receipt-verify beleid')
57
60
  console.log(' the same ten vectors under both amount policies, plus the two cases no')
58
61
  console.log(' published vector covers: overpayment, and one nonce across two payers.')
59
62
  console.log(' Those two are the open questions in x402 issue 2725.')
60
63
  console.log('')
61
- console.log(' npx github:holistis/agent-receipt-verify link-vectors')
64
+ console.log(' npx -y agent-receipt-verify liveness [out.json]')
65
+ console.log(' takes the census and asks each host whether it still answers 402 today.')
66
+ console.log(' A registration in an index is not a promise that the endpoint is alive.')
67
+ console.log('')
68
+ console.log(' npx -y agent-receipt-verify verify-delivery --answer=<file> --endpoint=<url>')
69
+ console.log(' reads, at the source, whether a paid answer is TRUE. The location is')
70
+ console.log(' DERIVED from --endpoint and never taken from the answer, because a seller')
71
+ console.log(' who picks both the token and the place can stage any outcome.')
72
+ console.log('')
73
+ console.log(' npx -y agent-receipt-verify link-vectors')
62
74
  console.log(' twelve vectors for the half AFTER paying: does this receipt belong to')
63
75
  console.log(' that one settlement? Eleven run offline, the chain-reading one is')
64
76
  console.log(' marked and skipped rather than failed.')
77
+ console.log('')
78
+ console.log('What this does NOT do: it judges the SHAPE of a receipt, not the honesty of')
79
+ console.log('the party that issued it. See CONFORMANCE.md for what is deliberately left')
80
+ console.log('unmeasured.')
65
81
  }
66
82
 
67
83
  // Het argument bepaalt welke module draait. De modules zijn ook los te draaien en te
@@ -76,6 +92,7 @@ const onderdelen = {
76
92
  kanon: '../src/eip3009-autorisatie.mjs',
77
93
  beleid: '../bench/eip3009-beleid.mjs',
78
94
  'link-vectors': '../src/settlement-link-vectors.mjs',
95
+ 'verify-delivery': '../src/verify-delivery.mjs',
79
96
  }
80
97
 
81
98
  if (!eerste || eerste === '--help' || eerste === '-h' || eerste === 'help') {
@@ -94,7 +111,7 @@ if (!eerste || eerste === '--help' || eerste === '-h' || eerste === 'help') {
94
111
  process.argv = [process.argv[0], pad, ...args]
95
112
  await import('../src/x402-conformance-check.mjs')
96
113
  } else {
97
- console.log('onbekend: ' + eerste)
114
+ console.log('unknown command: ' + eerste)
98
115
  console.log('')
99
116
  gebruik()
100
117
  process.exitCode = 2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-receipt-verify",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "Checks x402 endpoint conformance before an agent pays, and links the receipt to its settlement.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -8,7 +8,7 @@
8
8
  "node": ">=20"
9
9
  },
10
10
  "scripts": {
11
- "test": "node test/interop.mjs && node test/canonical.mjs && node test/link-onchain.mjs && node test/profiel.mjs && node test/eip3009.mjs && node test/conformance.mjs && node test/jws-offer.mjs && node test/census.mjs && node test/poortwachters.mjs && node test/metingen-bundel.mjs && node test/mcp-server.mjs && node test/cli.mjs && node test/bundel.mjs && node test/settlement-link-vectors.mjs",
11
+ "test": "node test/interop.mjs && node test/canonical.mjs && node test/link-onchain.mjs && node test/profiel.mjs && node test/eip3009.mjs && node test/conformance.mjs && node test/jws-offer.mjs && node test/census.mjs && node test/poortwachters.mjs && node test/metingen-bundel.mjs && node test/mcp-server.mjs && node test/cli.mjs && node test/bundel.mjs && node test/settlement-link-vectors.mjs && node test/naar-kanon-vorm.mjs && node test/verify-delivery.mjs && node test/acceptance-binding.mjs",
12
12
  "link-example": "node src/x402-link-onchain.mjs --receipt=examples/receipt-live-base-sepolia.json && node src/x402-link-onchain.mjs --receipt=examples/receipt-live-base-sepolia-2.json && node src/x402-link-onchain.mjs --receipt=examples/receipt-live-base-sepolia-3.json",
13
13
  "conformance": "node src/x402-conformance-check.mjs",
14
14
  "bench": "node bench/robustness.mjs",
@@ -20,7 +20,9 @@
20
20
  "bundel": "node tools/bundel-bouw.mjs build/bundle",
21
21
  "kanon": "node src/eip3009-autorisatie.mjs --vectoren",
22
22
  "bench-eip3009": "node bench/eip3009-beleid.mjs",
23
- "link-vectors": "node src/settlement-link-vectors.mjs"
23
+ "link-vectors": "node src/settlement-link-vectors.mjs",
24
+ "verify-delivery": "node src/verify-delivery.mjs",
25
+ "kanon-vorm": "node tools/naar-kanon-vorm.mjs"
24
26
  },
25
27
  "optionalDependencies": {
26
28
  "ethers": "^6.17.0"
@@ -0,0 +1,309 @@
1
+ // Koppelt laag 3 aan laag 4: hoort dit acceptatierecord bij dit bonnetje.
2
+ //
3
+ // WAAROM DIT EEN EIGEN BESTAND IS EN NIET BIJ DE SETTLEMENT-LINK HOORT. De
4
+ // settlement-link-vectoren beantwoorden een andere vraag: opent de gezouten belofte in
5
+ // een bonnetje op precies een afrekening op de keten. Dat is betaalbewijs. Een
6
+ // acceptatierecord beantwoordt iets anders: heeft een onafhankelijke partij het geleverde
7
+ // bestand getoetst aan criteria die al vastlagen. Een uitgever die beide door elkaar
8
+ // haalt levert een lezer een vlaggetje "onderbouwd" waarvan niemand meer kan zien welke
9
+ // van de twee vragen nu eigenlijk beantwoord is. Daarom twee sets, twee uitkomsten.
10
+ //
11
+ // WAT HIER WEL GEMETEN WORDT. Uitsluitend de BINDING. Noemen het bonnetje en het
12
+ // acceptatierecord hetzelfde opgeleverde bestand, dezelfde machtiging en dezelfde belofte,
13
+ // en lagen de criteria vast voordat er getoetst werd.
14
+ //
15
+ // WAT HIER MET OPZET NIET GEMETEN WORDT, en dat is geen tekortkoming maar de grens van
16
+ // deze laag. Wij toetsen de handtekening van het acceptatierecord NIET, en wij
17
+ // beoordelen zijn criteria NIET. Dat is het werk van de controleur die het record
18
+ // uitgaf, met zijn eigen sleutel en zijn eigen verifieerder. Wie hier een groen antwoord
19
+ // krijgt weet dus dat de twee bewijsstukken over hetzelfde gaan, en niet dat het
20
+ // acceptatierecord zelf klopt. Die twee door elkaar halen is precies de fout die deze
21
+ // module bestaat om te voorkomen.
22
+ //
23
+ // GEMETEN OP EEN ECHT RECORD, 18 oktober 2026, en dat veranderde deze module wezenlijk.
24
+ // De uitgever publiceerde een live record (invinoveritas, trilatic.acceptance-receipt/1.2).
25
+ // Daaruit bleek dat onze eerste aannames over de VORM niet klopten:
26
+ //
27
+ // 1. De hash van het bonnetje staat BOVENIN de sidecar, naast `bindings`, niet erin.
28
+ // 2. De hash van het opgeleverde bestand staat in het ONDERTEKENDE record, op
29
+ // `record.receipt.files.remediated.sha256`, en helemaal niet in de sidecar.
30
+ // 3. De partijen staan genest in `record.receipt.parties`, met een `key_id` dat leeg
31
+ // kan zijn, en niet als twee platte tekstvelden.
32
+ // 4. Het tijdstip van de toets heet `issued_utc`, niet `checked_at`.
33
+ // 5. De x402-velden zijn TWEE aparte sleutels, en dat is met opzet zo: een belofte mag
34
+ // nooit met een nonce vergeleken worden. `x402_receipt_commitment` hoort bij
35
+ // `authorizationCommitment`, `x402_authorization_nonce` bij `authorizationNonce`.
36
+ //
37
+ // Daarom leest deze module nu de ECHTE vorm, met de vlakke vorm uit onze eerste
38
+ // vectoren als tweede mogelijkheid. Een module die alleen zijn eigen verzonnen vorm
39
+ // aankan, toetst niets in de wereld.
40
+ //
41
+ // EEN AANNAME DIE WEG IS. Onze eerste versie nam aan dat een machtiging in de sidecar
42
+ // gelijk zou zijn aan de nonce in het bonnetje, en las daarvoor ook een generiek veld
43
+ // `authorization`. Dat generieke veld is nu bewust GESCHRAPT. De uitgever splitste de
44
+ // sleutels juist om te voorkomen dat een belofte met een nonce vergeleken wordt, en een
45
+ // generieke terugval zou die bescherming meteen weer weggooien.
46
+
47
+ const HEX32 = /^[0-9a-f]{64}$/
48
+
49
+ const genormaliseerdeHash = (w) => {
50
+ if (typeof w !== 'string') return null
51
+ const kaal = w.trim().toLowerCase().replace(/^0x/, '')
52
+ return HEX32.test(kaal) ? kaal : null
53
+ }
54
+
55
+ const gelijkeHash = (a, b) => {
56
+ const x = genormaliseerdeHash(a)
57
+ const y = genormaliseerdeHash(b)
58
+ return x !== null && y !== null && x === y
59
+ }
60
+
61
+ const tijdstip = (w) => {
62
+ if (typeof w === 'number' && Number.isFinite(w)) return w
63
+ if (typeof w !== 'string' || !w.trim()) return null
64
+ const ms = Date.parse(w)
65
+ return Number.isFinite(ms) ? ms / 1000 : null
66
+ }
67
+
68
+ const tekst = (w) => (typeof w === 'string' && w.trim() ? w.trim().toLowerCase() : null)
69
+
70
+ /**
71
+ * Normaliseert zowel het ECHTE record als de vlakke vorm uit onze eerste vectoren naar
72
+ * de velden die deze module vergelijkt. Een veld dat niet te vinden is wordt null, nooit
73
+ * een gok, want een gegokt veld levert een oordeel op dat niemand kan narekenen.
74
+ *
75
+ * De `naamruimte` bij elke partij is er met opzet: een key_id en een weergavenaam zijn
76
+ * twee verschillende soorten namen, en die mag je niet met elkaar vergelijken. Zie
77
+ * onafhankelijkheid() voor waarom dat hier het verschil maakt tussen een hard oordeel en
78
+ * een onmeetbaar geval.
79
+ */
80
+ export function leesRecord(doc) {
81
+ const d = doc && typeof doc === 'object' ? doc : {}
82
+ const echt = d.record?.receipt ?? null
83
+ const acceptance = d.acceptance ?? null
84
+ const sidecar = d.bindings_sidecar ?? d.sidecar ?? null
85
+
86
+ const partij = (knoop, plat) => {
87
+ if (knoop && typeof knoop === 'object') {
88
+ if (tekst(knoop.key_id)) return { id: tekst(knoop.key_id), naamruimte: 'key_id' }
89
+ if (tekst(knoop.name)) return { id: tekst(knoop.name), naamruimte: 'name' }
90
+ return { id: null, naamruimte: null }
91
+ }
92
+ return { id: tekst(plat), naamruimte: tekst(plat) ? 'plat' : null }
93
+ }
94
+
95
+ return {
96
+ // De hash van het opgeleverde bestand, zoals het ONDERTEKENDE record hem noemt.
97
+ acceptatieLevering:
98
+ echt?.files?.remediated?.sha256 ?? acceptance?.deliverable_sha256 ?? d.deliverable_sha256 ?? null,
99
+ // De hash van het bonnetje, bovenin de sidecar.
100
+ sidecarBonHash: sidecar?.receipt_sha256 ?? d.bindings?.receipt_sha256 ?? null,
101
+ // De x402-sleutels, in het subobject dat daarvoor bedoeld is.
102
+ sidecarBindings: sidecar?.bindings ?? d.bindings ?? {},
103
+ // De vlakke vorm uit onze eerste vectoren noemde de levering ook in de sidecar. Die
104
+ // staat in een echt record NIET in de sidecar, dus dit is puur terugwaartse steun.
105
+ sidecarLevering: d.bindings?.deliverable_sha256 ?? sidecar?.deliverable_sha256 ?? null,
106
+ controleur: partij(echt?.parties?.checker, acceptance?.checker ?? d.checker),
107
+ producent: partij(echt?.parties?.producer, acceptance?.producer ?? d.producer),
108
+ onafhankelijkheidVerklaard: tekst(echt?.independence ?? acceptance?.independence ?? null),
109
+ criteriaVastgelegd:
110
+ echt?.criteria?.committed_at ?? acceptance?.criteria_committed_at ?? d.criteria_committed_at ?? null,
111
+ getoetstOp: echt?.issued_utc ?? acceptance?.checked_at ?? d.checked_at ?? null,
112
+ schema: echt?.schema ?? acceptance?.format ?? null,
113
+ }
114
+ }
115
+
116
+ export const ONAFHANKELIJKHEID = [
117
+ 'VERIFIEERBAAR_ONAFHANKELIJK',
118
+ 'NIET_ONAFHANKELIJK',
119
+ 'ZELFVERKLAARD_ONVERIFIEERBAAR',
120
+ 'ONBEKEND',
121
+ ]
122
+
123
+ /**
124
+ * Kan een DERDE narekenen dat de controleur niet de producent is?
125
+ *
126
+ * Dit is bewust een drieweg en geen ja of nee, en daar zit de hele waarde. Het live
127
+ * record van 18 oktober 2026 zegt `independence: "independent"` terwijl
128
+ * `parties.producer.key_id` LEEG is. Er is dus niets om de controleur tegen af te zetten:
129
+ * de onafhankelijkheid is door de uitgever zelf verklaard en door niemand anders na te
130
+ * rekenen. Dat is geen afkeuring en geen goedkeuring, het is onmeetbaar, en precies dat
131
+ * onderscheid gaat verloren in een boolean.
132
+ *
133
+ * Even belangrijk: een key_id vergelijken met een weergavenaam is GEEN vergelijking. Dat
134
+ * zijn twee verschillende soorten namen, en "ze zijn verschillend dus onafhankelijk" zou
135
+ * dan een conclusie zijn die uit het formaat komt en niet uit de werkelijkheid.
136
+ */
137
+ export function onafhankelijkheid(doc) {
138
+ const r = leesRecord(doc)
139
+ const c = r.controleur
140
+ const p = r.producent
141
+ if (c.id === null && p.id === null) {
142
+ return { staat: 'ONBEKEND', reden: 'geen van beide partijen is benoemd', vergelekenOp: null }
143
+ }
144
+ if (c.id === null || p.id === null) {
145
+ return {
146
+ staat: r.onafhankelijkheidVerklaard === 'independent' ? 'ZELFVERKLAARD_ONVERIFIEERBAAR' : 'ONBEKEND',
147
+ reden:
148
+ 'een van beide partijen heeft geen identiteit in het record, dus een derde kan niet narekenen ' +
149
+ 'of de controleur en de producent dezelfde partij zijn',
150
+ vergelekenOp: null,
151
+ }
152
+ }
153
+ if (c.naamruimte !== p.naamruimte) {
154
+ return {
155
+ staat: r.onafhankelijkheidVerklaard === 'independent' ? 'ZELFVERKLAARD_ONVERIFIEERBAAR' : 'ONBEKEND',
156
+ reden:
157
+ `de partijen zijn in verschillende soorten namen vastgelegd (${c.naamruimte} tegen ${p.naamruimte}), ` +
158
+ 'en die mag je niet met elkaar vergelijken',
159
+ vergelekenOp: null,
160
+ }
161
+ }
162
+ if (c.id === p.id) {
163
+ return { staat: 'NIET_ONAFHANKELIJK', reden: 'de controleur en de producent zijn dezelfde partij', vergelekenOp: c.naamruimte }
164
+ }
165
+ return { staat: 'VERIFIEERBAAR_ONAFHANKELIJK', reden: null, vergelekenOp: c.naamruimte }
166
+ }
167
+
168
+ // Is er genoeg om uberhaupt een oordeel te vellen? Ontbreekt er iets aan ONZE kant van de
169
+ // vergelijking, dan is het antwoord NOT_MEASURED en nooit NOT_LINKED. Een ontbrekend veld
170
+ // als "niet gekoppeld" lezen zou een ongemeten geval als een afkeuring laten tellen.
171
+ export function bindingMeetbaar(doc) {
172
+ const r = leesRecord(doc)
173
+ const mist = []
174
+ if (genormaliseerdeHash(r.sidecarBonHash) === null) mist.push('sidecar.receipt_sha256')
175
+ if (genormaliseerdeHash(r.acceptatieLevering) === null) mist.push('record.receipt.files.remediated.sha256')
176
+ return mist.length ? { meetbaar: false, mist } : { meetbaar: true, mist: [] }
177
+ }
178
+
179
+ // De twee voorwaarden van de uitgever zelf (trilatic.acceptance-receipt/1.2): een record
180
+ // telt alleen als de controleur niet de producent is, en als de criteria vastlagen
181
+ // voordat er getoetst werd. Ze staan hier in code omdat een voorwaarde die alleen in
182
+ // proza staat bij de eerste drukke dag wordt overgeslagen.
183
+ //
184
+ // De uitkomst is drieledig, want "ik kan het niet nagaan" is iets anders dan "het is
185
+ // mis". Dat onderscheid is dezelfde correctie die de Kanon-auteur op 17 oktober 2026
186
+ // overnam voor zijn eigen controleur.
187
+ export function recordTelt(doc) {
188
+ const r = leesRecord(doc)
189
+ const o = onafhankelijkheid(doc)
190
+ if (o.staat === 'NIET_ONAFHANKELIJK') return { telt: false, meetbaar: true, reden: 'CHECKER_IS_PRODUCER' }
191
+ if (o.staat !== 'VERIFIEERBAAR_ONAFHANKELIJK') {
192
+ return { telt: false, meetbaar: false, reden: 'INDEPENDENCE_NOT_VERIFIABLE', toelichting: o.reden }
193
+ }
194
+ const vastgelegd = tijdstip(r.criteriaVastgelegd)
195
+ const getoetst = tijdstip(r.getoetstOp)
196
+ if (vastgelegd === null || getoetst === null) {
197
+ return { telt: false, meetbaar: false, reden: 'CRITERIA_TIMING_UNKNOWN' }
198
+ }
199
+ if (!(vastgelegd < getoetst)) {
200
+ return { telt: false, meetbaar: true, reden: 'CRITERIA_NOT_COMMITTED_FIRST' }
201
+ }
202
+ return { telt: true, meetbaar: true, reden: null }
203
+ }
204
+
205
+ /**
206
+ * Het eigenlijke oordeel. Drie uitkomsten, nooit twee: gekoppeld, niet gekoppeld, of
207
+ * niet te meten.
208
+ *
209
+ * `receiptSha256` en het bonnetje zelf komen van de AANROEPER, want dat is de kant die
210
+ * hij in handen heeft. Alles aan de record-kant wordt uit het document gelezen.
211
+ */
212
+ export function koppelAcceptatie({ receipt, acceptance, bindings, sidecar, doc, receiptSha256, deliverableSha256 }) {
213
+ // Een aanroeper mag het hele document meegeven, of de losse stukken zoals onze eerste
214
+ // vectoren dat deden. Beide komen hier op dezelfde genormaliseerde vorm uit.
215
+ const document = doc ?? {
216
+ record: acceptance?.record ? acceptance.record : undefined,
217
+ acceptance,
218
+ bindings,
219
+ bindings_sidecar: sidecar,
220
+ }
221
+ const r = leesRecord(document)
222
+
223
+ const m = bindingMeetbaar(document)
224
+ if (!m.meetbaar) {
225
+ return { valid: false, measurable: false, reason_code: 'NOT_MEASURED', missing: m.mist, independence: onafhankelijkheid(document).staat }
226
+ }
227
+
228
+ const telt = recordTelt(document)
229
+ if (!telt.telt) {
230
+ return telt.meetbaar
231
+ ? { valid: false, measurable: true, reason_code: 'NOT_LINKED', detail: telt.reden, independence: onafhankelijkheid(document).staat }
232
+ : {
233
+ valid: false,
234
+ measurable: false,
235
+ reason_code: 'NOT_MEASURED',
236
+ missing: [telt.reden],
237
+ detail: telt.toelichting ?? null,
238
+ independence: onafhankelijkheid(document).staat,
239
+ }
240
+ }
241
+
242
+ // Noemt de sidecar het bonnetje dat wij in handen hebben? Zonder een meegegeven hash van
243
+ // ons eigen bonnetje kan die helft niet getoetst worden, en dan is het niet gemeten.
244
+ if (receiptSha256 !== undefined) {
245
+ if (genormaliseerdeHash(receiptSha256) === null) {
246
+ return { valid: false, measurable: false, reason_code: 'NOT_MEASURED', missing: ['receiptSha256'] }
247
+ }
248
+ if (!gelijkeHash(receiptSha256, r.sidecarBonHash)) {
249
+ return { valid: false, measurable: true, reason_code: 'NOT_LINKED', detail: 'RECEIPT_HASH_MISMATCH' }
250
+ }
251
+ }
252
+
253
+ // De kern: wijzen het record en wat wij in handen hebben naar hetzelfde opgeleverde
254
+ // bestand. De aanroeper mag de hash van het bestand dat hij ECHT ontving meegeven; dat
255
+ // is sterker dan een hash die ook uit de sidecar komt, want dan vergelijk je twee
256
+ // velden van dezelfde uitgever met elkaar.
257
+ const onzeKant = deliverableSha256 ?? r.sidecarLevering
258
+ if (onzeKant === null || onzeKant === undefined) {
259
+ return {
260
+ valid: false,
261
+ measurable: false,
262
+ reason_code: 'NOT_MEASURED',
263
+ missing: ['deliverableSha256'],
264
+ detail: 'het record noemt een opgeleverd bestand, maar er is geen eigen hash om het tegen af te zetten',
265
+ independence: onafhankelijkheid(document).staat,
266
+ }
267
+ }
268
+ if (!gelijkeHash(r.acceptatieLevering, onzeKant)) {
269
+ return { valid: false, measurable: true, reason_code: 'NOT_LINKED', detail: 'DELIVERABLE_HASH_MISMATCH' }
270
+ }
271
+
272
+ // De twee x402-sleutels, ELK met zijn eigen tegenhanger. Een belofte wordt nooit met
273
+ // een nonce vergeleken: daarvoor zijn het twee sleutels. Staat een van de twee kanten
274
+ // niet in het stuk, dan wordt er niet vergeleken, en dat is geen afkeuring.
275
+ const b = r.sidecarBindings ?? {}
276
+ const paren = [
277
+ {
278
+ naam: 'COMMITMENT_MISMATCH',
279
+ sidecar: b.x402_receipt_commitment,
280
+ bon: receipt?.authorizationCommitment ?? receipt?.payload?.authorizationCommitment,
281
+ },
282
+ {
283
+ naam: 'AUTHORIZATION_MISMATCH',
284
+ sidecar: b.x402_authorization_nonce,
285
+ bon: receipt?.authorizationNonce ?? receipt?.payload?.authorizationNonce,
286
+ },
287
+ ]
288
+ const vergeleken = []
289
+ for (const p of paren) {
290
+ if (p.sidecar === undefined || p.sidecar === null) continue
291
+ if (p.bon === undefined || p.bon === null) continue
292
+ if (!gelijkeHash(p.sidecar, p.bon)) {
293
+ return { valid: false, measurable: true, reason_code: 'NOT_LINKED', detail: p.naam }
294
+ }
295
+ vergeleken.push(p.naam.replace('_MISMATCH', ''))
296
+ }
297
+
298
+ return {
299
+ valid: true,
300
+ measurable: true,
301
+ reason_code: 'LINKED',
302
+ independence: onafhankelijkheid(document).staat,
303
+ // Wat er WEL en NIET vergeleken is, want een groen antwoord zonder dat lijstje laat
304
+ // een lezer denken dat alles getoetst is.
305
+ compared: vergeleken,
306
+ }
307
+ }
308
+
309
+ export const UITKOMSTEN = ['LINKED', 'NOT_LINKED', 'NOT_MEASURED']