@sanning/proof 0.19.1 → 0.20.0

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 (98) hide show
  1. package/README.md +30 -29
  2. package/dist/agent-record.d.ts +62 -0
  3. package/dist/agent-record.js +527 -0
  4. package/dist/agent-schemas.generated.d.ts +15 -0
  5. package/dist/agent-schemas.generated.js +5759 -0
  6. package/dist/case-file.d.ts +32 -89
  7. package/dist/case-file.js +41 -93
  8. package/dist/case.d.ts +339 -822
  9. package/dist/case.js +667 -1160
  10. package/dist/chain.d.ts +19 -47
  11. package/dist/chain.js +78 -205
  12. package/dist/claims.d.ts +211 -0
  13. package/dist/claims.js +1081 -0
  14. package/dist/cli.d.ts +45 -68
  15. package/dist/cli.js +618 -1022
  16. package/dist/compose.js +23 -65
  17. package/dist/crypto.d.ts +7 -8
  18. package/dist/crypto.js +60 -147
  19. package/dist/declaration.d.ts +68 -194
  20. package/dist/declaration.js +175 -518
  21. package/dist/environment.d.ts +9 -30
  22. package/dist/environment.js +17 -66
  23. package/dist/evidence.d.ts +119 -160
  24. package/dist/evidence.js +744 -1103
  25. package/dist/exit-codes.d.ts +11 -27
  26. package/dist/exit-codes.js +24 -59
  27. package/dist/file-match.d.ts +33 -79
  28. package/dist/file-match.js +33 -69
  29. package/dist/grading.d.ts +98 -135
  30. package/dist/grading.js +224 -380
  31. package/dist/handoff.d.ts +62 -160
  32. package/dist/handoff.js +153 -393
  33. package/dist/index.d.ts +14 -10
  34. package/dist/index.js +60 -167
  35. package/dist/json-schema.d.ts +29 -0
  36. package/dist/json-schema.js +298 -0
  37. package/dist/merkle.js +16 -28
  38. package/dist/message-parts.d.ts +56 -212
  39. package/dist/message-parts.js +142 -502
  40. package/dist/side-content.d.ts +11 -22
  41. package/dist/side-content.js +22 -65
  42. package/dist/types.d.ts +17 -0
  43. package/dist/types.js +19 -5
  44. package/dist/verifier.d.ts +40 -114
  45. package/dist/verifier.js +196 -535
  46. package/dist/witness-cache.d.ts +24 -45
  47. package/dist/witness-cache.js +38 -81
  48. package/dist/witness-lookup.d.ts +36 -57
  49. package/dist/witness-lookup.js +89 -139
  50. package/dist/zip-reader.d.ts +18 -33
  51. package/dist/zip-reader.js +19 -34
  52. package/package.json +11 -3
  53. package/src/agent-record.ts +608 -0
  54. package/src/agent-schemas.generated.ts +5764 -0
  55. package/src/case-file.ts +45 -109
  56. package/src/case.ts +814 -1328
  57. package/src/chain.ts +85 -224
  58. package/src/claims.ts +1253 -0
  59. package/src/cli.ts +714 -1176
  60. package/src/compose.ts +33 -79
  61. package/src/crypto.ts +60 -147
  62. package/src/declaration.ts +219 -656
  63. package/src/environment.ts +17 -65
  64. package/src/evidence.ts +958 -1381
  65. package/src/exit-codes.ts +24 -63
  66. package/src/file-match.ts +40 -89
  67. package/src/grading.ts +287 -421
  68. package/src/handoff.ts +175 -427
  69. package/src/index.ts +131 -177
  70. package/src/json-schema.ts +295 -0
  71. package/src/merkle.ts +16 -28
  72. package/src/message-parts.ts +155 -533
  73. package/src/side-content.ts +25 -68
  74. package/src/types.ts +55 -36
  75. package/src/verifier.ts +202 -526
  76. package/src/witness-cache.ts +45 -94
  77. package/src/witness-lookup.ts +107 -177
  78. package/src/zip-reader.ts +20 -38
  79. package/vectors/README.md +52 -34
  80. package/vectors/agent-record.json +2175 -0
  81. package/vectors/case-file-index.json +1 -1
  82. package/vectors/case-split.json +2 -2
  83. package/vectors/chain-reading.json +3 -3
  84. package/vectors/file-match.json +285 -148
  85. package/vectors/jcs-reader.json +6 -6
  86. package/vectors/pack-grading.json +102 -25
  87. package/vectors/sandbox-evidence.json +573 -581
  88. package/vectors/stamp-on-chain.json +18 -18
  89. package/vectors/witness-cache.json +49 -49
  90. package/dist/agent-proof.d.ts +0 -28
  91. package/dist/agent-proof.js +0 -226
  92. package/dist/run-adapters.d.ts +0 -10
  93. package/dist/run-adapters.js +0 -35
  94. package/dist/run.d.ts +0 -209
  95. package/dist/run.js +0 -1405
  96. package/src/agent-proof.ts +0 -264
  97. package/src/run-adapters.ts +0 -44
  98. package/src/run.ts +0 -1598
package/dist/evidence.js CHANGED
@@ -1,62 +1,36 @@
1
- // Evidence-bundle verification (sanning-io/proof specs/evidence-bundle.md).
1
+ // Evidence-bundle verification (specs/evidence-bundle.md).
2
2
  //
3
- // A `sanning.evidence/v1` bundle is a SIGNED wrapper around a producer-specific
4
- // `body`. This module verifies the `sanning.anchor.trace/v3` body, the
5
- // @sanning/anchor SDK trace: a self-verifying serialization of InclusionReceipt[]
6
- // (per-event signed envelopes + their checkpoints + RFC 9162 inclusion proofs).
7
- //
8
- // The algorithm is the kernel discipline applied one level up (evidence-bundle
9
- // §1, §4): inherit-don't-invent (same JCS, SHA-256, Ed25519), wrap-don't-merge
10
- // (the body is opaque to the wrapper), recompute-don't-trust-the-verdict
11
- // (every claim is re-derived from `body`, never read off `verdict.status`),
12
- // reject-unknown-major. No Sanning service is in the trust path: the bundle is
13
- // self-verifying offline against the embedded `public_key`; the optional
14
- // gateway re-fetch only confirms the checkpoints are anchored on-chain.
15
- //
16
- // Scope: this verifies a SINGLE evidence bundle. Chain walking over
17
- // `previous_hash` across successive exports is consumer-layer logic above it.
18
- import { deriveOperatorAddress, ed25519Verify, hexToBytes,
19
- // The one hex-field test, which lives in `crypto.ts` beside the one hex
20
- // reader (proof#146). It was a second copy here until then, with the same
21
- // body: two copies of one rule is how two kernels drift apart one edit at a
22
- // time, which is the whole defect class this fix is about.
23
- isHexField, sha256Hex, utf8, verifyRsaPssSha256, } from "./crypto.js";
3
+ // A `sanning.evidence/v1` bundle is a signed wrapper around a producer-specific
4
+ // `body`. Every claim is re-derived from `body` with the same JCS, SHA-256 and
5
+ // Ed25519 as the envelope layer, never read off `verdict.status`, and an
6
+ // unknown major is rejected. The bundle verifies offline against its embedded
7
+ // `public_key`; the optional gateway re-fetch only confirms checkpoints are
8
+ // witnessed on-chain.
9
+ import { readAgentRecords } from "./agent-record.js";
10
+ import { isObject } from "./json-schema.js";
11
+ import { deriveOperatorAddress, ed25519Verify, hexToBytes, isHexField, sha256Hex, utf8, verifyRsaPssSha256, } from "./crypto.js";
24
12
  import { safeInteger, signedLeafCount } from "./grading.js";
25
13
  import { isConformantHandoffRecord, isHandoffNamespace } from "./handoff.js";
26
14
  import { leafHash, verifyInclusion } from "./merkle.js";
27
- import { DuplicateKeyError, MAX_CANONICAL_DEPTH, contentHashes, exceedsDepth, isRfc3339UtcInstant, jcs, jcsParse, verifyEnvelope, } from "./verifier.js";
15
+ import { MAX_CANONICAL_DEPTH, contentHashes, exceedsDepth, isRfc3339UtcInstant, jcs, jcsParse, specVersionSupported, verifyEnvelope, } from "./verifier.js";
28
16
  import { inBodyContentOffered, sideContentEntry, } from "./side-content.js";
29
- // Only the evidence-bundle major(s) this kernel verifies. A new major is a
30
- // deliberate one-entry addition HERE — same fail-closed discipline as the
31
- // envelope verifier's ACCEPTED_SPEC_MAJORS.
32
- /** The accepted `sanning.evidence/v1` wrapper majors; exported in both kernels (P-01, 0.13.0). */
17
+ import { ENVELOPE_FAULTS } from "./types.js";
18
+ // Fail-closed, like ACCEPTED_SPEC_MAJORS: a new major is a one-entry addition here.
19
+ /** The accepted evidence-bundle wrapper majors. */
33
20
  export const ACCEPTED_EVIDENCE_MAJORS = Object.freeze(["sanning.evidence/v1"]);
34
- // The body_type this module knows how to verify structurally. An unknown
35
- // body_type is not a hard failure of the WRAPPER (the signature + body_hash
36
- // still verify), but its body cannot be re-derived, so the per-body verdict is
37
- // "undetermined" rather than "verified".
38
- //
39
- // The producer-to-agent cut (W-3, DL-73) renamed `record_bytes` to
40
- // `event_bytes` and moved every body that carries it to a new major. This
41
- // kernel reads ONLY those majors: `sanning.anchor.trace/v1` and `/v2`,
42
- // `sanning.stamp.evidence/v1` and `sanning.evidence.export/v1` are unknown body
43
- // types here and answer `partial`. DL-73 releases evidence made before the cut
44
- // from any obligation to keep verifying, so there is no second field name to
45
- // read and no dual-accept path.
21
+ // The anchor-trace body this module verifies structurally. An unknown
22
+ // body_type does not fail the wrapper, but its body cannot be re-derived, so
23
+ // it reads `incomplete`. Bodies before the `event_bytes` majors are unknown here.
46
24
  export const ANCHOR_TRACE_BODY_TYPE = "sanning.anchor.trace/v3";
47
25
  /**
48
- * The name `event_bytes` replaced. Under the new majors a body entry that still
49
- * carries it is a hard reject, never an ignored unknown field: ignored, it
50
- * would read as a withheld record and the event would verify with nothing
51
- * checked, the fail-open the new majors exist to prevent (governance.md §8,
52
- * W-3).
26
+ * The field name `event_bytes` replaced. A body entry still carrying it is a
27
+ * hard reject: ignored, it would read as a withheld record and verify with
28
+ * nothing checked.
53
29
  */
54
30
  export const LEGACY_EVENT_BYTES_FIELD = "record_bytes";
55
31
  /**
56
- * Name an envelope the accept-set refused. Without this a bundle whose
57
- * envelopes carry a retired profile major (a half-migrated producer still
58
- * writing `sanning.events/v1`) fails with no reason at all. Spelled with
59
- * `JSON.stringify` so both kernels write the same sentence.
32
+ * Name an envelope the accept-set refused, so a retired profile major fails
33
+ * with a reason. The same sentence in both kernels.
60
34
  */
61
35
  function specVersionFinding(what, envelope, res) {
62
36
  if (!isJsonObject(envelope) || res.specVersionOk)
@@ -67,68 +41,39 @@ function specVersionFinding(what, envelope, res) {
67
41
  const sv = envelope.spec_version;
68
42
  return `${what} envelope spec_version ${JSON.stringify(sv)} is not accepted by this kernel`;
69
43
  }
70
- /**
71
- * The reject for an entry that still carries `record_bytes`. One sentence,
72
- * spelled the same in both kernels, so a negative vector can assert it.
73
- */
44
+ /** The reject for an entry that still carries `record_bytes`. The same sentence in both kernels. */
74
45
  function legacyEventBytesError(entry, what, bodyType) {
75
46
  if (!Object.prototype.hasOwnProperty.call(entry, LEGACY_EVENT_BYTES_FIELD))
76
47
  return null;
77
48
  return (`${what} carries ${LEGACY_EVENT_BYTES_FIELD} in a ${bodyType} body ` +
78
49
  "(renamed event_bytes) -- one bundle speaks one vocabulary");
79
50
  }
80
- // The issuer-composed attested-evidence-export body (specs/evidence-export.md
81
- // §2). The wrapper is unchanged (`sanning.evidence/v1`, Ed25519-signed); this body
82
- // carries a cached verdict + the inline source bundle + embedded RSA-PSS
83
- // operator attestations. verifyEvidenceBundle dispatches on it to
84
- // verifyExportBody (§5).
51
+ // The attested-evidence-export body (specs/evidence-export.md §2): a cached
52
+ // verdict, the inline source bundle and RSA-PSS operator attestations.
85
53
  export const EXPORT_BODY_TYPE = "sanning.evidence.export/v2";
86
54
  /**
87
- * The Sanning-signed interval stamp body (evidence-bundle.md §5.2, DL-58).
88
- *
89
- * Its own `body_type` rather than a widened anchor-trace body, and the reason
90
- * is the whole decision: an anchor-trace checkpoint carries a PRODUCER-signed
91
- * `sanning.events/v2` envelope, and a stamp has no producer signature at all:
92
- * Sanning places it, under DL-52, for a customer who holds no chain identity.
93
- * The two rejected options were making `envelope` optional and synthesising an
94
- * envelope that Sanning signs; the second would have made a Sanning
95
- * attestation indistinguishable ON THE WIRE from a producer's, which is
96
- * unrecoverable once witnessed.
55
+ * The Sanning-signed interval stamp body (evidence-bundle.md §5.2). Its own
56
+ * body type, because a stamp has no producer signature: a Sanning attestation
57
+ * must never be indistinguishable on the wire from a producer's.
97
58
  */
98
59
  export const STAMP_TRACE_BODY_TYPE = "sanning.stamp.trace/v1";
99
60
  export const STAMP_EVIDENCE_BODY_TYPE = "sanning.stamp.evidence/v2";
100
61
  /**
101
- * The version string INSIDE a witnessed stamp body — a different string from
102
- * the `body_type` above, and the two are easy to confuse.
103
- *
104
- * `sanning.stamp.trace/v1` discriminates the BUNDLE BODY: it is the reader's
105
- * dispatch key. `sanning.stamp/v1` is the control plane's own version of the
106
- * bytes it hands a witness (`control-plane/src/stamp-tree.ts`,
107
- * `STAMP_BODY_VERSION`). A `sanning.stamp.trace/v1` body pins the inner
108
- * version to exactly this value: two answers to one question inside one
109
- * signature is the §5.1.2 mixed-vocabulary reject, one layer down.
62
+ * The version string inside a witnessed stamp body, distinct from the
63
+ * `body_type` above (the dispatch key). A stamp trace body pins it to exactly
64
+ * this value (§5.1.2).
110
65
  */
111
66
  export const STAMP_BODY_VERSION = "sanning.stamp/v1";
112
67
  /**
113
- * A surface MUST NOT present a wrapper's `co_signatures` as evidence
114
- * (evidence-bundle.md §2, decided by Phil 2026-09-24 on proof#148): nothing
115
- * in either kernel verifies the field, and it sits outside the wrapper's
116
- * signed scope (`bundleForSig` below), so anyone holding a pack's bytes can
117
- * append it with no key at all. This is the one sentence both kernels attach
118
- * as a note, never a verdict, never an exit code.
68
+ * A wrapper's `co_signatures` is not evidence (evidence-bundle.md §2): nothing
69
+ * verifies it, and it sits outside the signed scope, so anyone can append it.
70
+ * Attached as a note, never a verdict or exit code.
119
71
  */
120
72
  export const CO_SIGNATURES_NOTE = "unverified co-signatures are present in this pack's wrapper and are not part of the evidence";
121
73
  /**
122
- * True iff the wrapper's reserved `co_signatures` field carries at least one
123
- * entry.
124
- *
125
- * Absence and an empty array read the same way here: the field asserts
126
- * nothing about a second signer until it holds one, and evidence-bundle.md's
127
- * own carve-out language for the analogous envelope field treats "absent"
128
- * and "an empty array" as one thing (envelope-spec.md §7.1). A non-array
129
- * value (malformed shape, but the field is reserved, not yet specified)
130
- * still counts as present: it is not the well-formed absence either rule
131
- * describes.
74
+ * Whether the wrapper's reserved `co_signatures` carries at least one entry.
75
+ * Absent and empty read the same (envelope-spec.md §7.1); a non-array value
76
+ * counts as present.
132
77
  */
133
78
  export function coSignaturesPresent(b) {
134
79
  if (b === null || typeof b !== "object" || Array.isArray(b))
@@ -156,22 +101,18 @@ function isEvidenceMajorAccepted(specVersion) {
156
101
  return minor.length > 0 && /^[0-9]+$/.test(minor);
157
102
  });
158
103
  }
104
+ /** evidence-bundle.md §2. Byte-identical to the Python kernel's. */
105
+ export const GENERATED_AT_MALFORMED = "malformed generated_at: not an RFC 3339 UTC date-time with Z naming a real date and time (evidence-bundle section 2)";
159
106
  /**
160
- * A bundle this kernel cannot read.
161
- *
162
- * `wrapperOk` is true only where the wrapper signature and `body_hash` were
163
- * CHECKED and held before the body was found unreadable. It is then reported
164
- * as held, because it did: `signatureOk` and `bodyHashOk` are statements about
165
- * the wrapper, and a body this kernel cannot parse does not unsign it. The
166
- * case reader decides whose pack a bundle is on exactly those two fields
167
- * (`evidence-bundle.md` 5.5.1), so reporting `false` here let a holder who
168
- * broke its own body shape read as a forger.
107
+ * A bundle this kernel cannot read. `wrapperOk` is true only where the wrapper
108
+ * signature and `body_hash` were checked and held: an unparseable body does not
109
+ * unsign the wrapper, and the case reader decides whose pack it is on those
110
+ * fields (§5.5.1).
169
111
  */
170
- /** evidence-bundle §2 (A31). Byte-identical to the Python kernel's. */
171
- export const GENERATED_AT_MALFORMED = "malformed generated_at: not an RFC 3339 UTC date-time with Z naming a real date and time (evidence-bundle §2)";
172
112
  function malformed(error, specVersionOk = false, wrapperOk = false) {
173
113
  return {
174
- status: "malformed",
114
+ status: "incomplete",
115
+ unreadable: true,
175
116
  specVersionOk,
176
117
  bodyHashOk: wrapperOk,
177
118
  signatureOk: wrapperOk,
@@ -179,93 +120,136 @@ function malformed(error, specVersionOk = false, wrapperOk = false) {
179
120
  onChainChecked: false,
180
121
  checkpoints: [],
181
122
  events: [],
182
- assertedStatus: null,
183
123
  errors: [error],
184
124
  };
185
125
  }
186
- /**
187
- * The prefixes of the exception text this kernel writes ITSELF, in the words
188
- * the Python kernel writes too. Anything outside this set comes from the host:
189
- * a JSON parser, a codec, a crypto library. See `thrownDetail`.
190
- */
191
- // `duplicate key ` is `DuplicateKeyError` in `verifier.ts`, which
192
- // `DuplicateKey` in the Python twin's `canonicalize.py` words identically.
126
+ // The prefixes of exception text this kernel writes itself, identically in
127
+ // Python. Anything else comes from the host. See `thrownDetail`.
193
128
  const KERNEL_WORDING = ["hexToBytes: ", "duplicate key "];
194
129
  /**
195
- * The detail a finding may append about a caught exception.
196
- *
197
- * THE RULE: a finding carries the text of a caught exception only where this
198
- * kernel wrote that text. A finding travels into a case reading through `case`
199
- * `reasons`, and both kernels promise one reading and one rendered sentence for
200
- * one set of bytes; a host JSON parser says `Unexpected token 'h', "hello" is
201
- * not valid JSON` here and `Expecting value: line 1 column 1 (char 0)` there,
202
- * for one pack anyone can build (gate 4 on proof#140, finding 1).
203
- *
204
- * Returns `": <text>"` for this kernel's own wording and the empty string for
205
- * anything else. The label at the call site already names the check that
206
- * failed and both kernels agree on it character for character; what is dropped
207
- * is the host's parse offset, which no reader can act on and which was never
208
- * the same in the two kernels.
130
+ * The record as the agent reader holds it, or `null` where it takes no part:
131
+ * only a `sanning.events/v2` record whose envelope verified and whose bytes
132
+ * bound to it (section 10).
209
133
  */
134
+ function agentItem(ev, eventBytes, bound) {
135
+ if (!bound || eventBytes === undefined)
136
+ return null;
137
+ const envelope = ev.envelope;
138
+ if (!isObject(envelope) || !specVersionSupported(envelope.spec_version, AGENT_RECORD_PROFILES)) {
139
+ return null;
140
+ }
141
+ let record;
142
+ try {
143
+ record = jcsParse(eventBytes);
144
+ }
145
+ catch {
146
+ return null;
147
+ }
148
+ return {
149
+ event_id: envelope.event_id,
150
+ public_key: envelope.public_key,
151
+ record,
152
+ };
153
+ }
154
+ // The profile whose records the agent vocabulary governs (agent-events.md section 3).
155
+ const AGENT_RECORD_PROFILES = ["sanning.events/v2"];
156
+ /** Read the bundle's records as one pile and put each agent record's reading on its result. */
157
+ async function attachAgentReadings(results, items) {
158
+ const pile = items.filter((item) => item !== null);
159
+ const readings = await readAgentRecords(pile);
160
+ let next = 0;
161
+ for (const [i, item] of items.entries()) {
162
+ if (item !== null)
163
+ results[i].agent = readings[next++];
164
+ }
165
+ }
210
166
  /**
211
- * The kernel's `duplicate key "<name>"` when a record repeats a member name,
212
- * else `undefined` (proof#144; `_record_duplicate_key` in the Python twin).
213
- *
214
- * ONLY A DUPLICATE, and not every unreadable record. Every reader below this
215
- * one that parses a record treats an unreadable one as absent, so the content
216
- * binding reads undetermined and the pack said "1 record discloses no bytes"
217
- * about a record whose bytes it had read. A record that repeats a name is
218
- * a defect in the artifact, and a defect this kernel can see fails, the rule
219
- * `event_bytes` that are not hex already follow.
167
+ * The finding for record bytes a producer signed that are not JSON, else
168
+ * `undefined` (`_record_not_json` in the Python twin): `event record is not
169
+ * JSON`, with the kernel's `duplicate key "<name>"` where the bytes repeat a
170
+ * member name. The bytes are signed, so the defect is the producer's and
171
+ * visible, and the record fails (evidence-bundle.md section 4), never reads as
172
+ * withheld.
220
173
  */
221
- function recordDuplicateKey(eventBytes) {
174
+ function recordNotJson(eventBytes) {
222
175
  if (eventBytes === undefined)
223
176
  return undefined;
224
177
  try {
225
178
  jcsParse(eventBytes);
226
179
  }
227
180
  catch (e) {
228
- if (e instanceof DuplicateKeyError)
229
- return e.message;
181
+ return `event record is not JSON${thrownDetail(e)}`;
230
182
  }
231
183
  return undefined;
232
184
  }
185
+ /**
186
+ * The detail a finding may append about a caught exception: `": <text>"` for
187
+ * this kernel's own wording, `""` for anything else. Host parser messages
188
+ * differ by kernel, and both kernels must render one sentence for one set of
189
+ * bytes.
190
+ */
233
191
  function thrownDetail(e) {
234
192
  const text = e instanceof Error ? e.message : String(e);
235
193
  return KERNEL_WORDING.some((w) => text.startsWith(w)) ? `: ${text}` : "";
236
194
  }
237
195
  /**
238
- * Decode `bytes` as UTF-8 the way every other kernel does: a byte sequence that
239
- * is not valid UTF-8 THROWS, and a leading byte-order mark is kept rather than
240
- * eaten.
241
- *
242
- * ONE CALLER LEFT, and it is not a parse. `jcsParse` (`verifier.ts`) reads
243
- * every set of signed bytes in this kernel now; this function survives only for
244
- * the canonicality comparison on the stamp path, which needs the DECODED TEXT
245
- * of bytes `jcsParse` has already accepted, to compare against `jcs(body)`. It
246
- * performs exactly the decode `jcsParse` performs, so the comparison cannot
247
- * read the bytes differently from the parse that produced `body`.
248
- *
249
- * `new TextDecoder()` alone is neither strict nor BOM-preserving: it replaces
250
- * every invalid sequence with U+FFFD and strips a BOM. A stamp body carrying
251
- * one raw `0x80` inside a string value read `verified` here and `failed` there
252
- * because of it (gate 4 on proof#140). The symmetric claim the first version of
253
- * this comment made, that "Python's `json.loads` raises on both", is FALSE and
254
- * is what hid gate 5's defect for a round: `json.loads(<bytes>)` strips a BOM
255
- * too. Python's strict reader is `jcs_loads`, and the rule both kernels hold to
256
- * is stated once, in `jcsParse`.
196
+ * Decode `bytes` as strict UTF-8, keeping a leading BOM: the decode `jcsParse`
197
+ * performs, for the stamp path's canonicality comparison on bytes `jcsParse`
198
+ * already accepted.
257
199
  */
258
200
  function decodeUtf8Strict(bytes) {
259
201
  return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
260
202
  }
261
203
  /**
262
204
  * Name an envelope `public_key` or `signature` spelled outside the hex charset
263
- * or at the wrong length (proof#142). This kernel's envelope layer already
264
- * fails such an envelope, and the Python one's decoder accepts ASCII
265
- * whitespace (proof#135), so both bundle verifiers name it here, in the same
266
- * words, and the two kernels give one reading. Only a STRING is checked: a
267
- * missing or non-string field is the envelope layer's to fail, and it does.
205
+ * or at the wrong length, in the same words as Python, so both kernels give one
206
+ * reading. Only a string is checked; a missing one is the envelope layer's to
207
+ * fail.
208
+ */
209
+ /**
210
+ * The envelope faults that leave a claim not proven rather than disproven: the record's
211
+ * envelope or key cannot be read, or its version is one this verifier does not know.
212
+ */
213
+ export const UNPROVEN_ENVELOPE_FAULTS = [
214
+ "not_an_envelope",
215
+ "key_unreadable",
216
+ "spec_version_unknown",
217
+ ];
218
+ /**
219
+ * A record that fails only on something not proven: a stamp or checkpoint the pack does not
220
+ * hold, an envelope or key that cannot be read, or a version this verifier does not know,
221
+ * while every check that runs without it holds (the binding, the inclusion proof and the
222
+ * content). It holds the bundle at `incomplete`, never `failed`.
223
+ */
224
+ function unproven(e) {
225
+ const envelopeUnproven = e.envelopeFault !== undefined && e.envelopeFault !== null && UNPROVEN_ENVELOPE_FAULTS.includes(e.envelopeFault);
226
+ const stampAbsent = e.stampAbsent === true;
227
+ return ((stampAbsent || envelopeUnproven) &&
228
+ (e.envelopeOk || envelopeUnproven) &&
229
+ (envelopeUnproven || e.ownFormFailed !== true) &&
230
+ e.payloadBindingOk !== false &&
231
+ e.contentOk !== false &&
232
+ (stampAbsent || (e.inclusionOk && e.checkpointBound)));
233
+ }
234
+ /**
235
+ * Why a record's envelope does not verify, worst first: the envelope layer's own faults,
236
+ * and, where only this kernel's strict hex reading refuses a field, a key that cannot be read
237
+ * or a signature that does not hold. A field's form is not judged under either.
268
238
  */
239
+ function eventEnvelopeFaults(faults, hexError) {
240
+ const found = new Set(faults);
241
+ if (hexError !== null) {
242
+ found.delete("nonconforming");
243
+ if (hexError.includes("public_key")) {
244
+ found.delete("signature_failed");
245
+ found.add("key_unreadable");
246
+ }
247
+ else {
248
+ found.add("signature_failed");
249
+ }
250
+ }
251
+ return ENVELOPE_FAULTS.filter((f) => found.has(f));
252
+ }
269
253
  function envelopeHexError(what, envelope) {
270
254
  if (!isJsonObject(envelope))
271
255
  return null;
@@ -281,30 +265,20 @@ function envelopeHexError(what, envelope) {
281
265
  return null;
282
266
  }
283
267
  /**
284
- * Spell a value inside a finding the way the Python kernel spells it.
285
- *
286
- * `JSON.stringify(undefined)` is `undefined` (the value, not a string), so an
287
- * absent field interpolates as the word `undefined` where Python writes `null`.
288
- * One word, and a real difference in what an auditor reads: someone comparing
289
- * the two CLIs on one bundle sees two different sentences for one fact. Both
290
- * kernels route every quoted value through this spelling, and the cross-kernel
291
- * anchor-trace leg byte-compares them.
268
+ * Spell a value inside a finding as Python does: an absent value reads `null`,
269
+ * never `undefined`.
292
270
  */
293
271
  function jsonStr(value) {
294
272
  return JSON.stringify(value === undefined ? null : value) ?? "null";
295
273
  }
296
- // Verify a `sanning.evidence/v1` bundle and dispatch on its `body_type`.
297
- //
298
- // Returns a structured per-checkpoint + per-event result and a recomputed
299
- // rollup `status`. The producer's asserted `verdict.status` is surfaced
300
- // (`assertedStatus`) but NEVER trusted — the displayed status is the one this
301
- // function recomputes (evidence-bundle §1 principle 3 / §4 step 4).
274
+ /**
275
+ * Verify a `sanning.evidence/v1` bundle, dispatching on its `body_type`. The
276
+ * returned `status` is recomputed from the body; a wrapper `verdict` member,
277
+ * where a producer still writes one, is never read (evidence-bundle.md section 4).
278
+ */
302
279
  export async function verifyEvidenceBundle(bundle, options = {}) {
303
- // The ONE place a present `co_signatures` becomes a note, so every caller
304
- // (including `case.ts`, which calls this function directly per pack) gets
305
- // it, however the wrapper otherwise verifies. Never touches `status` or
306
- // the exit code: a bundle that would otherwise verify still verifies, note
307
- // or not (proof#148).
280
+ // The one place a present `co_signatures` becomes a note, so `case.ts` gets
281
+ // it too. Never touches `status`.
308
282
  const result = await verifyBundle(bundle, options, false);
309
283
  const note = coSignaturesNote(bundle);
310
284
  if (note !== undefined) {
@@ -313,31 +287,49 @@ export async function verifyEvidenceBundle(bundle, options = {}) {
313
287
  return result;
314
288
  }
315
289
  // `exportSource`: read the bundle as an export's source (evidence-export.md
316
- // §2.4), where the only recognised body is `sanning.anchor.trace/v3`.
290
+ // §2.4), where only `sanning.anchor.trace/v3` is recognised.
317
291
  async function verifyBundle(bundle, options, exportSource) {
318
- // --- Step 1: parse + reject unknown spec_version major ---------------------
292
+ // Step 1: parse and reject an unknown spec_version major.
319
293
  if (bundle === null || typeof bundle !== "object" || Array.isArray(bundle)) {
320
294
  return malformed("evidence bundle is not a JSON object");
321
295
  }
322
- // Reject a pathologically deep bundle BEFORE any canonicalization (which
323
- // recurses). A fixed shared bound keeps this a `malformed` verdict in every
324
- // kernel rather than a stack-limit-dependent split verdict — see
325
- // MAX_CANONICAL_DEPTH.
296
+ // Reject a pathologically deep bundle before canonicalization recurses (see
297
+ // MAX_CANONICAL_DEPTH).
326
298
  if (exceedsDepth(bundle, MAX_CANONICAL_DEPTH)) {
327
299
  return malformed(`evidence bundle nesting exceeds the ${MAX_CANONICAL_DEPTH}-level canonicalization depth bound`);
328
300
  }
329
301
  const b = bundle;
330
302
  const specVersionOk = isEvidenceMajorAccepted(b.spec_version);
331
303
  if (!specVersionOk) {
332
- // `?? null`: an ABSENT field is spelled `null`, as the Python twin's
333
- // `_json_str` spells it. `JSON.stringify(undefined)` is `undefined`, and
334
- // the two kernels printed two different sentences for one bundle until
335
- // `verify --json` compared them byte for byte (0.13.0).
336
- return malformed(`unsupported evidence spec_version: ${JSON.stringify(b.spec_version ?? null)}`, false);
304
+ // An absent field is spelled `null`, as in Python.
305
+ const unknown = malformed(`unsupported evidence spec_version: ${JSON.stringify(b.spec_version ?? null)}`, false);
306
+ unknown.versionUnknown = typeof b.spec_version === "string";
307
+ if (unknown.versionUnknown && typeof b.public_key === "string" && typeof b.signature === "string") {
308
+ // A version this verifier does not know still carries a signature it can check: one
309
+ // that fails is a pack changed after it was signed.
310
+ const found = [];
311
+ const [keyFormOk, signatureOk] = await checkWrapperSignature(b, found);
312
+ if (keyFormOk && !signatureOk) {
313
+ return {
314
+ status: "failed",
315
+ specVersionOk: false,
316
+ // Not checked under this version, so not the disproof here.
317
+ bodyHashOk: true,
318
+ signatureOk: false,
319
+ bodyType: null,
320
+ onChainChecked: false,
321
+ checkpoints: [],
322
+ events: [],
323
+ errors: [...unknown.errors, ...found],
324
+ versionUnknown: true,
325
+ };
326
+ }
327
+ unknown.signatureOk = signatureOk;
328
+ }
329
+ return unknown;
337
330
  }
338
331
  if (typeof b.signature_alg === "string" && b.signature_alg !== "ed25519") {
339
- // The reference verifier implements ed25519 only (evidence-bundle §2 +
340
- // decision #2); other algs are a deliberate future addition.
332
+ // Ed25519 only (evidence-bundle.md §2).
341
333
  return malformed(`unsupported signature_alg: ${JSON.stringify(b.signature_alg ?? null)}`, true);
342
334
  }
343
335
  if (typeof b.public_key !== "string" || typeof b.signature !== "string") {
@@ -347,20 +339,16 @@ async function verifyBundle(bundle, options, exportSource) {
347
339
  return malformed("evidence bundle is missing body_hash", true);
348
340
  }
349
341
  if (b.body === undefined || b.body === null) {
350
- // body_ref (out-of-line body) is a defined growth hook but not part of the
351
- // anchor-trace use case — refuse rather than silently pass.
342
+ // `body_ref` (out-of-line body) is refused rather than passed.
352
343
  return malformed("evidence bundle has no inline body (body_ref unsupported here)", true);
353
344
  }
354
- // --- Step 2: verify the wrapper signature + recompute body_hash ------------
345
+ // Step 2: verify the wrapper signature and recompute `body_hash`.
355
346
  const errors = [];
356
- // Every wrapper hex field is read STRICTLY, before any decoder sees it
357
- // (proof#142). The Python decoder skipped ASCII whitespace, and the wrapper
358
- // `signature` sits outside the signed scope, so one space in it, added by
359
- // anyone and with no key, made a pack verify there and fail here. The same
360
- // named failure in both kernels, for each field: `public_key` and
361
- // `signature` are 64 and 128 hex characters in either case (proof#135,
362
- // option B), and `body_hash` is 64 LOWER-case hex characters, the form it is
363
- // recomputed in and compared against.
347
+ // Every wrapper hex field is read strictly, before any decoder sees it:
348
+ // `signature` is outside the signed scope, so a lenient decoder would let
349
+ // anyone flip a verdict by adding whitespace. `public_key` and `signature`
350
+ // are 64 and 128 hex characters in either case; `body_hash` is 64 lowercase,
351
+ // the form it is recomputed in.
364
352
  let bodyHashOk = false;
365
353
  if (!isHexField(b.body_hash, 64, true)) {
366
354
  errors.push("wrapper body_hash is not 64 lowercase hex characters");
@@ -377,6 +365,69 @@ async function verifyBundle(bundle, options, exportSource) {
377
365
  errors.push(`body canonicalization failed${thrownDetail(e)}`);
378
366
  }
379
367
  }
368
+ const [keyFormOk, signatureOk] = await checkWrapperSignature(b, errors);
369
+ // A key that cannot be read checks no signature: `signed` is not proven on that claim
370
+ // only. A `body_hash` that does not hold still fails, and a body that holds is still read,
371
+ // so a disproof inside it stands.
372
+ if (!keyFormOk && bodyHashOk) {
373
+ // Form is judged only under a signature that holds, so the wrapper's time is not.
374
+ const result = await readWrappedBody(b, specVersionOk, errors, options, exportSource, false);
375
+ result.signatureOk = false;
376
+ result.keyUnreadable = true;
377
+ if (result.status === "verified")
378
+ result.status = "incomplete";
379
+ return result;
380
+ }
381
+ if (!keyFormOk) {
382
+ return {
383
+ status: "failed",
384
+ specVersionOk,
385
+ bodyHashOk,
386
+ signatureOk: false,
387
+ bodyType: typeof b.body_type === "string" ? b.body_type : null,
388
+ onChainChecked: false,
389
+ checkpoints: [],
390
+ events: [],
391
+ errors,
392
+ keyUnreadable: true,
393
+ };
394
+ }
395
+ // A broken wrapper is a hard failure: the body is untrustworthy.
396
+ if (!signatureOk || !bodyHashOk) {
397
+ return {
398
+ status: "failed",
399
+ specVersionOk,
400
+ bodyHashOk,
401
+ signatureOk,
402
+ bodyType: typeof b.body_type === "string" ? b.body_type : null,
403
+ onChainChecked: false,
404
+ checkpoints: [],
405
+ events: [],
406
+ errors,
407
+ };
408
+ }
409
+ return readWrappedBody(b, specVersionOk, errors, options, exportSource);
410
+ }
411
+ /** Everything a bundle says past its wrapper: its time, then its body by type. */
412
+ async function readWrappedBody(b, specVersionOk, errors, options, exportSource, judgeTime = true) {
413
+ const result = await readBody(b, specVersionOk, errors, options, exportSource);
414
+ // The wrapper's `generated_at` (evidence-bundle.md §2) is printed beside every verdict
415
+ // and copied into `as_of`, so a present value that is not RFC 3339 UTC is a format the
416
+ // producer broke. The body is read first, so a disproof inside it stands beside this one.
417
+ if (judgeTime &&
418
+ Object.prototype.hasOwnProperty.call(b, "generated_at") &&
419
+ !isRfc3339UtcInstant(b.generated_at)) {
420
+ result.status = "failed";
421
+ result.generatedAtMalformed = true;
422
+ result.errors = [GENERATED_AT_MALFORMED, ...result.errors.filter((e) => e !== GENERATED_AT_MALFORMED)];
423
+ }
424
+ return result;
425
+ }
426
+ /**
427
+ * `[keyFormOk, signatureOk]` for the wrapper's Ed25519 signature over every field but
428
+ * `signature` and `co_signatures`. `public_key` and `signature` are strings here.
429
+ */
430
+ async function checkWrapperSignature(b, errors) {
380
431
  let signatureOk = false;
381
432
  const keyFormOk = isHexField(b.public_key, 64);
382
433
  const signatureFormOk = isHexField(b.signature, 128);
@@ -395,108 +446,71 @@ async function verifyBundle(bundle, options, exportSource) {
395
446
  errors.push(`wrapper signature verification error${thrownDetail(e)}`);
396
447
  }
397
448
  }
398
- const assertedStatus = typeof b.verdict?.status === "string" ? b.verdict.status : null;
399
- // A broken wrapper (bad signature or tampered body) is a hard failure — the
400
- // body is untrustworthy, so we do not pretend to verify its contents.
401
- if (!signatureOk || !bodyHashOk) {
402
- return {
403
- status: "failed",
404
- specVersionOk,
405
- bodyHashOk,
406
- signatureOk,
407
- bodyType: typeof b.body_type === "string" ? b.body_type : null,
408
- onChainChecked: false,
409
- checkpoints: [],
410
- events: [],
411
- assertedStatus,
412
- errors,
413
- };
414
- }
415
- // --- The wrapper's own time (evidence-bundle §2, A31) ---------------------
416
- // `generated_at` is copied into a recomputed verdict's `as_of` (the export
417
- // reading) and printed beside every verdict, so a present value that is not
418
- // the RFC 3339 form `signed_at` already pins is a wrapper this kernel cannot
419
- // render, whatever its signature. It is read only once the wrapper holds: a
420
- // broken wrapper is `failed` above, as it was. Absence is unchanged.
421
- if (Object.prototype.hasOwnProperty.call(b, "generated_at") &&
422
- !isRfc3339UtcInstant(b.generated_at)) {
423
- return malformed(GENERATED_AT_MALFORMED, specVersionOk, true);
424
- }
425
- // --- Body dispatch ---------------------------------------------------------
449
+ return [keyFormOk, signatureOk];
450
+ }
451
+ /** A wrapped body, read by its type. */
452
+ async function readBody(b, specVersionOk, errors, options, exportSource) {
426
453
  const bodyType = typeof b.body_type === "string" ? b.body_type : null;
427
- // Read as an export's source (evidence-export.md §2.4), the only body this
428
- // kernel recognises is sanning.anchor.trace/v3. Any other body, even one it
429
- // verifies on its own, is undetermined here, after the wrapper checks above.
454
+ // As an export's source, only sanning.anchor.trace/v3 is recognised; any
455
+ // other body is undetermined.
430
456
  if (exportSource && bodyType !== ANCHOR_TRACE_BODY_TYPE) {
431
457
  errors.push(`body_type ${JSON.stringify(bodyType)} is not an export source ` +
432
- `(only ${ANCHOR_TRACE_BODY_TYPE}); wrapper verified, body undetermined`);
458
+ `(only ${ANCHOR_TRACE_BODY_TYPE}); wrapper verified, body not checked`);
433
459
  return {
434
- status: "partial",
460
+ status: "incomplete",
435
461
  specVersionOk,
436
- bodyHashOk,
437
- signatureOk,
462
+ bodyHashOk: true,
463
+ signatureOk: true,
438
464
  bodyType,
439
465
  onChainChecked: false,
440
466
  checkpoints: [],
441
467
  events: [],
442
- assertedStatus,
443
468
  errors,
444
469
  };
445
470
  }
446
- // Attested-evidence-export body (evidence-export.md §5). The wrapper is
447
- // authentic (Ed25519 issuer signature + body_hash verified above, §5 step 2);
448
- // hand off to the export verifier for steps 3–9.
471
+ // The wrapper is authentic (§5 step 2); the export verifier runs steps 3 to 9.
449
472
  if (bodyType === EXPORT_BODY_TYPE) {
450
- return verifyExportBody(b, specVersionOk, assertedStatus, errors, options);
473
+ return verifyExportBody(b, specVersionOk, errors, options);
451
474
  }
452
- // The Sanning-signed interval stamp body (evidence-bundle.md §5.2). The
453
- // wrapper is authentic — and for this body that IS "Sanning signed it", since
454
- // a stamp carries no second signature of its own inside the bundle.
475
+ // The stamp body (§5.2). For this body the wrapper signature is what
476
+ // "Sanning signed it" means: a stamp carries no second signature in the bundle.
455
477
  if (bodyType === STAMP_TRACE_BODY_TYPE) {
456
- return verifyStampTrace(b, specVersionOk, assertedStatus, errors, options);
478
+ return verifyStampTrace(b, specVersionOk, errors, options);
457
479
  }
458
- // The DL-52 hand-over body (§5.3): stamps AND the customer's events, joined.
480
+ // The hand-over body (§5.3): stamps and the customer's events, joined.
459
481
  if (bodyType === STAMP_EVIDENCE_BODY_TYPE) {
460
- return verifyStampEvidence(b, specVersionOk, assertedStatus, errors, options);
482
+ return verifyStampEvidence(b, specVersionOk, errors, options);
461
483
  }
462
484
  if (bodyType !== ANCHOR_TRACE_BODY_TYPE) {
463
- // The wrapper is authentic but we don't know this body's structure. Per
464
- // wrap-don't-merge, that is "partial" (cryptographically sound wrapper,
465
- // body verdict undetermined) — not a failure.
485
+ // An authentic wrapper over an unknown body: `incomplete`, not a failure.
466
486
  errors.push(`body_type ${JSON.stringify(bodyType)} is not verifiable by this kernel ` +
467
- `(only ${ANCHOR_TRACE_BODY_TYPE}); wrapper verified, body undetermined`);
487
+ `(only ${ANCHOR_TRACE_BODY_TYPE}); wrapper verified, body not checked`);
468
488
  return {
469
- status: "partial",
489
+ status: "incomplete",
470
490
  specVersionOk,
471
- bodyHashOk,
472
- signatureOk,
491
+ bodyHashOk: true,
492
+ signatureOk: true,
473
493
  bodyType,
474
494
  onChainChecked: false,
475
495
  checkpoints: [],
476
496
  events: [],
477
- assertedStatus,
478
497
  errors,
479
498
  };
480
499
  }
481
- return verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, options);
500
+ return verifyAnchorTrace(b, specVersionOk, errors, options);
482
501
  }
483
502
  /**
484
- * Recompute an export's source verdict (evidence-export.md §5 step 4).
485
- *
486
- * The only export source is `sanning.anchor.trace/v3` (§2.2, §2.4). Any other
487
- * inline source body is unrecognised *as a source*, even one this kernel
488
- * verifies on its own, such as a `sanning.stamp.evidence/v2` pack: §2.4 makes
489
- * its source verdict undetermined, so the export cannot reach `verified`. The
490
- * source wrapper is still checked first, because a forged source is a failure
491
- * whatever its body.
503
+ * Recompute an export's source verdict (evidence-export.md §5 step 4). Only a
504
+ * `sanning.anchor.trace/v3` source is recognised (§2.4); any other reads
505
+ * undetermined, so the export cannot reach `verified`. The source wrapper is
506
+ * checked first, because a forged source fails whatever its body.
492
507
  */
493
508
  async function verifyExportSource(source, options) {
494
509
  return verifyBundle(source, options, true);
495
510
  }
496
511
  /**
497
- * Index a source bundle's checkpoints by the key its attestations use: the
498
- * derived `checkpoint_ref`. Only a `sanning.anchor.trace/v3` source has
499
- * checkpoints an attestation can resolve (evidence-export.md §2.4).
512
+ * Index a source bundle's checkpoints by derived `checkpoint_ref`, the key
513
+ * attestations use (evidence-export.md §2.4).
500
514
  */
501
515
  async function indexSourceCheckpoints(bundle) {
502
516
  const out = new Map();
@@ -523,25 +537,14 @@ async function indexSourceCheckpoints(bundle) {
523
537
  return out;
524
538
  }
525
539
  /**
526
- * Resolve a checkpoint's join key and its witness reference.
527
- *
528
- * ── Why a retired spelling is a reject rather than a preference ──
529
- *
530
- * A checkpoint carrying a retired spelling beside the current one is two
531
- * answers to one question inside one signature. A reader that silently prefers
532
- * one gives a different answer from a reader that prefers the other, over the
533
- * same signed bytes, and both look like they verified. So `tx_id` and
534
- * `record_bytes` are rejected rather than resolved.
535
- *
536
- * ── Why the key is recomputed ──
537
- *
538
- * The key IS the bytes: relabelling a checkpoint and every event pointing at it
539
- * fails here, because nothing is taken from the label.
540
+ * Resolve a checkpoint's join key and witness reference. The key is recomputed
541
+ * from the bytes, never taken from the label. A retired spelling (`tx_id`,
542
+ * `record_bytes`) beside the current one is two answers inside one signature,
543
+ * so it is rejected rather than resolved.
540
544
  */
541
545
  async function resolveCheckpointKey(cp, eventBytes) {
542
546
  const errors = [];
543
- // Every early return below leaves before `anchor_ref` is ever looked at, so
544
- // nothing there is "malformed": it is unexamined, which is not the same claim.
547
+ // Early returns never look at `anchor_ref`, so it is unexamined, not malformed.
545
548
  const unexamined = { key: null, anchorRef: null, anchorRefMalformed: false, errors };
546
549
  if (cp.tx_id !== undefined) {
547
550
  errors.push(`${ANCHOR_TRACE_BODY_TYPE} checkpoint carries legacy tx_id -- one bundle speaks one vocabulary`);
@@ -575,33 +578,12 @@ async function resolveCheckpointKey(cp, eventBytes) {
575
578
  };
576
579
  }
577
580
  /**
578
- * Validate an optional `anchor_ref` (§5.1.1).
579
- *
580
- * ── An unrecognised `kind` is NOT a failure, and that is the point ──
581
- *
582
- * The offline verdict — signatures, payload binding, Merkle inclusion — is
583
- * computed with no witness access at all. A reader that cannot resolve a
584
- * witness has therefore learned NOTHING about whether the evidence was
585
- * altered, and must not say otherwise. The shipped issuer did the opposite:
586
- * an unrecognised signature type returned `false` rather than `null`, mapped
587
- * to `tampered`, and was cached as permanent — a false accusation, produced
588
- * by a coverage gap, for a company whose product is telling people whether
589
- * records were altered.
590
- *
591
- * So this validates the SHAPE and never the vocabulary. A malformed
592
- * `anchor_ref` is a defect in the bundle; an unknown `kind` is a defect in
593
- * the reader, and only the first is the bundle's fault.
581
+ * Validate an optional `anchor_ref` (§5.1.1): its shape, never its vocabulary.
594
582
  *
595
- * ── Absent and malformed are not the same null (proof#120) ──
596
- *
597
- * Both leave no `AnchorRef` to re-fetch, and both used to be reported the same
598
- * way downstream: an all-`unreachable` on-chain result, although no gateway was
599
- * asked in either case. For the ABSENT case that is at least the note
600
- * `evidence-bundle.md` §5.1.1 reserves for "a checkpoint with no `anchor_ref`".
601
- * For the MALFORMED case it is a manufactured outage: the bundle named a
602
- * witness, the name was unreadable, and the reader was told the public record
603
- * could not be reached. So the two come back as different facts, and `malformed`
604
- * carries the difference.
583
+ * An unrecognised `kind` is not a failure: the offline verdict needs no witness,
584
+ * so a reader that cannot resolve one has learned nothing about alteration and
585
+ * must not accuse. A malformed `anchor_ref` is a defect in the bundle, and is
586
+ * returned as `malformed` so it is never reported as an unreachable witness.
605
587
  */
606
588
  function parseAnchorRef(raw, errors) {
607
589
  if (raw === undefined || raw === null) {
@@ -634,7 +616,7 @@ function parseAnchorRef(raw, errors) {
634
616
  malformed: false,
635
617
  };
636
618
  }
637
- async function verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, options) {
619
+ async function verifyAnchorTrace(b, specVersionOk, errors, options) {
638
620
  const body = b.body;
639
621
  const bodyType = ANCHOR_TRACE_BODY_TYPE;
640
622
  if (!Array.isArray(body.checkpoints) || !Array.isArray(body.events)) {
@@ -642,31 +624,17 @@ async function verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, optio
642
624
  }
643
625
  const gateways = options.gateways ?? [];
644
626
  const onChainChecked = gateways.length > 0;
645
- // --- Step 3: per-checkpoint -------------------------------------------------
646
- //
647
- // ── Why the join key is resolved BEFORE anything else ──
648
- //
649
- // `byKey` is what binds an event to the root its inclusion proof is checked
650
- // against. Get it wrong and an event proves inclusion in a tree it was never
651
- // in — the one failure in this file that produces a WRONG VERIFIED, not a
652
- // failed verify. Everything below is downstream of it.
627
+ // Step 3: per checkpoint. `byKey` binds each event to the root its inclusion
628
+ // is checked against; a wrong key here would yield a wrong `verified`.
653
629
  const byKey = new Map();
654
630
  const anchorByKey = new Map();
655
631
  const checkpointResults = [];
656
- // proof#120. Kept beside the results rather than on them: "the bundle named
657
- // a witness we could not read" is an input defect the checkpoint's own
658
- // `errors` already states, not a new dimension of the published verdict, and
659
- // `CheckpointResult` is a shape downstream surfaces render.
632
+ // Kept beside the results: an unreadable witness name is an input defect the
633
+ // checkpoint's `errors` already states, not a verdict dimension.
660
634
  const malformedAnchorRef = new Set();
661
635
  for (const rawCp of body.checkpoints) {
662
- // `checkpoints[]` is attacker-shaped, and a verifier must never raise on
663
- // adversarial input: a null entry used to reach `cp.tx_id` and hand the
664
- // caller a TypeError instead of a verdict, which an auditor cannot tell
665
- // apart from a broken tool. Graded and reported as "not a checkpoint"
666
- // rather than coerced into an empty one: probing the envelope layer with
667
- // nothing asks it a question about an object that was never an envelope.
668
- // Same treatment, same words, as the §5.3 path's `events[] entry is not an
669
- // object`.
636
+ // `checkpoints[]` is attacker-shaped; a verifier never raises on it. A
637
+ // non-object entry is reported as not a checkpoint, never coerced.
670
638
  if (rawCp === null || typeof rawCp !== "object" || Array.isArray(rawCp)) {
671
639
  checkpointResults.push({
672
640
  checkpointRef: "",
@@ -685,8 +653,7 @@ async function verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, optio
685
653
  let envelopeOk = false;
686
654
  let merkleRootOk = false;
687
655
  let leafCountOk = false;
688
- // Decode event_bytes first: in v2 the join key is DERIVED from it, so it
689
- // is an input to identity, not just to the payload binding below.
656
+ // The join key is derived from `event_bytes`, so decode them first.
690
657
  let eventBytes;
691
658
  try {
692
659
  if (typeof cp.event_bytes === "string")
@@ -697,15 +664,9 @@ async function verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, optio
697
664
  }
698
665
  const keyed = await resolveCheckpointKey(cp, eventBytes);
699
666
  cpErrors.push(...keyed.errors);
700
- // A duplicate key is a HARD failure, not a note. `byKey` below keeps the
701
- // FIRST occurrence, so a second checkpoint claiming the same key is
702
- // verified against the first one's envelope and its own stated identity is
703
- // never independently checked. Recording the finding and leaving `ok` true
704
- // let the bundle roll up to `verified` while carrying a checkpoint nothing
705
- // had verified: a false pass, which is what `evidence-bundle.md` §3
706
- // principle 3 exists to prevent (the displayed verdict must be the
707
- // recomputed one, and here the recomputation quietly dropped a finding it
708
- // had made).
667
+ // A duplicate key is a hard failure: `byKey` keeps the first, so a second
668
+ // checkpoint's identity would otherwise never be checked and the bundle
669
+ // could roll up `verified` over it.
709
670
  let duplicateKey = false;
710
671
  if (keyed.key !== null) {
711
672
  if (byKey.has(keyed.key)) {
@@ -717,17 +678,14 @@ async function verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, optio
717
678
  anchorByKey.set(keyed.key, keyed.anchorRef);
718
679
  }
719
680
  }
720
- // Verify the checkpoint envelope against its committed record (external
721
- // commitment — the record is the payload these external envelopes bind to).
681
+ // The record is the payload these external-commitment envelopes bind to.
722
682
  try {
723
683
  const res = await verifyEnvelope(cp.envelope, eventBytes !== undefined ? { payloadBytes: eventBytes } : {});
724
- // A checkpoint must fully bind: signature + the committed record matching
725
- // payload_hash. An undetermined binding (no record) is not enough to
726
- // anchor the events' inclusion claims to a trustworthy root.
684
+ // A checkpoint must fully bind: an undetermined binding cannot anchor the
685
+ // events' inclusion claims to a trustworthy root.
727
686
  const hexError = envelopeHexError("checkpoint", cp.envelope);
728
687
  envelopeOk = res.ok && res.payloadHashOk === true && hexError === null;
729
- // See the event loop below: a degenerate envelope is a malformed input,
730
- // not a failed check (proof#76).
688
+ // A degenerate envelope is malformed input, not a failed check.
731
689
  if (!isJsonObject(cp.envelope)) {
732
690
  cpErrors.push("checkpoint envelope is not a JSON object");
733
691
  }
@@ -739,19 +697,15 @@ async function verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, optio
739
697
  }
740
698
  if (res.payloadHashOk === false)
741
699
  cpErrors.push("checkpoint record does not bind to payload_hash");
742
- // proof#95: a malformed skeleton field fails the envelope with a valid
743
- // signature and binding; name it rather than fail with no reason.
700
+ // Name a malformed skeleton field, which fails an otherwise valid envelope.
744
701
  for (const m of res.errors)
745
702
  if (m.startsWith("malformed "))
746
703
  cpErrors.push(`checkpoint envelope ${m}`);
747
704
  const specFinding = specVersionFinding("checkpoint", cp.envelope, res);
748
705
  if (specFinding !== null)
749
706
  cpErrors.push(specFinding);
750
- // WHY the binding is undetermined decides which sentence is true.
751
- // event_bytes really absent is one thing; event_bytes present and
752
- // unbindable (a degenerate envelope, unreadable hex) is another, and
753
- // naming the wrong cause sends a reader to look for a field that is
754
- // sitting right there (proof#76).
707
+ // Name why the binding is undetermined: absent bytes and unbindable bytes
708
+ // are different causes.
755
709
  if (res.payloadHashOk === null) {
756
710
  cpErrors.push(eventBytes === undefined
757
711
  ? "checkpoint event_bytes missing -- root untrusted"
@@ -761,8 +715,7 @@ async function verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, optio
761
715
  catch (e) {
762
716
  cpErrors.push(`checkpoint envelope verify error${thrownDetail(e)}`);
763
717
  }
764
- // The committed record's merkle_root must equal the checkpoint's claimed
765
- // merkle_root (recompute, don't trust the bundle's top-level claim).
718
+ // Recompute: the committed record's merkle_root must equal the claimed one.
766
719
  if (eventBytes !== undefined) {
767
720
  try {
768
721
  const record = jcsParse(eventBytes);
@@ -772,12 +725,9 @@ async function verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, optio
772
725
  cpErrors.push(`checkpoint merkle_root mismatch: claimed=${jsonStr(cp.merkle_root)} ` +
773
726
  `committed=${jsonStr(committedRoot)}`);
774
727
  }
775
- // §5.1, A38 (proof#197), decided by Phil 2026-10-06: fail closed. The
776
- // count the record signs is what every inclusion bound to this
777
- // checkpoint is held to, so a record that signs no integer count fails
778
- // the checkpoint, as a record that signs no merkle_root does. Read by
779
- // value through the one reader the inclusion compare and the
780
- // completeness notes share, so `3.0` and `3e0` are 3.
728
+ // Fail closed (§5.1): every inclusion is held to the count the record
729
+ // signs, so a record that signs no integer count fails the checkpoint.
730
+ // Read by value through the shared reader, so `3.0` and `3e0` are 3.
781
731
  leafCountOk = signedLeafCount(cp) !== null;
782
732
  if (!leafCountOk)
783
733
  cpErrors.push("checkpoint record leaf_count is not an integer");
@@ -793,25 +743,30 @@ async function verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, optio
793
743
  merkleRootOk,
794
744
  onChainOk: null, // filled in step 5 if a gateway re-fetch was requested
795
745
  onChain: null, // per-gateway (§4.2), filled in step 5
796
- // `keyed.errors` is load-bearing here, not decorative. A structurally
797
- // invalid anchor_ref rides INSIDE the signature, so it is a defect we
798
- // can see rather than a coverage gap we cannot — and this kernel's
799
- // standing rule is that malformed is a failure while absent is
800
- // undetermined (the same rule disclosed `content` follows). Without
801
- // this term the vector `trace-v2-malformed-anchor-ref` verified GREEN.
746
+ // A malformed `anchor_ref` is inside the signature: a visible defect,
747
+ // which fails, while an absent one is undetermined.
802
748
  ok: envelopeOk && merkleRootOk && leafCountOk && keyed.errors.length === 0 && !duplicateKey,
803
749
  errors: cpErrors,
750
+ anchorRefMalformed: keyed.anchorRefMalformed && envelopeOk && merkleRootOk && leafCountOk && !duplicateKey,
804
751
  });
805
752
  if (keyed.anchorRefMalformed) {
806
753
  malformedAnchorRef.add(checkpointResults[checkpointResults.length - 1]);
807
754
  }
808
755
  }
809
- // --- Step 4: per-event ------------------------------------------------------
756
+ // Every checkpoint ref the pack holds, derived or stated, compared as hex is. A ref
757
+ // outside it names a checkpoint the pack does not hold, which is a stamp missing, never a
758
+ // stamp proven wrong (section 5.1, as section 5.3.3 rule 1 reads a stamp).
759
+ const heldRefs = new Set([...byKey.keys()].map((k) => k.toLowerCase()));
760
+ for (const raw of body.checkpoints) {
761
+ const ref = isJsonObject(raw) ? raw.checkpoint_ref : undefined;
762
+ if (typeof ref === "string")
763
+ heldRefs.add(ref.toLowerCase());
764
+ }
765
+ // Step 4: per event.
810
766
  const eventResults = [];
767
+ const agentItems = [];
811
768
  for (const rawEv of body.events) {
812
- // Same rule as `checkpoints[]` above, and the same words the §5.3 path
813
- // already uses: a null or non-object entry is graded as not an event,
814
- // never raised on.
769
+ // A non-object entry is graded as not an event, never raised on.
815
770
  if (rawEv === null || typeof rawEv !== "object" || Array.isArray(rawEv)) {
816
771
  eventResults.push({
817
772
  eventId: "<unknown>",
@@ -822,34 +777,23 @@ async function verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, optio
822
777
  contentOk: null,
823
778
  ok: false,
824
779
  errors: ["events[] entry is not an object"],
780
+ agent: null,
825
781
  });
782
+ agentItems.push(null);
826
783
  continue;
827
784
  }
828
785
  const ev = rawEv;
829
786
  const evErrors = [];
830
787
  const eventId = getEventId(ev.envelope);
831
788
  let envelopeOk = false;
789
+ let envelopeFaults = [];
832
790
  let payloadBindingOk = null;
833
- // A retired field name under this major is a reject, never an ignored
834
- // unknown field: ignored, it reads as a withheld record (W-3).
791
+ // A retired field name is a reject: ignored, it would read as withheld.
835
792
  const legacyError = legacyEventBytesError(ev, "event", bodyType);
836
793
  if (legacyError !== null)
837
794
  evErrors.push(legacyError);
838
- // MALFORMED IS A FAILURE; ABSENT IS UNDETERMINED (proof#85).
839
- //
840
- // `event_bytes` that will not decode leaves `eventBytes` undefined, and an
841
- // undefined `eventBytes` is exactly how a WITHHELD record is spelled one
842
- // line down. So an event carrying garbage in place of its record used to
843
- // reach the same tri-state as an event carrying nothing: `payloadHashOk`
844
- // null, `ok` true, a leading tick on the line, and a `verified` rollup over
845
- // a record nobody could read. The only thing wrong with the pack was the
846
- // bytes, and it was the one thing the verdict did not say.
847
- //
848
- // A record the producer chose not to disclose is the customer exercising
849
- // minimum disclosure. A record that is present and unreadable is a defect
850
- // in the artifact, and this kernel's standing rule (the same one
851
- // `checkpoints[]` already applies through `keyed.errors`) is that a defect
852
- // we can see fails.
795
+ // Malformed is a failure; absent is undetermined. Undecodable `event_bytes`
796
+ // would otherwise look exactly like a withheld record and verify.
853
797
  let eventBytes;
854
798
  let eventBytesMalformed = false;
855
799
  try {
@@ -860,24 +804,20 @@ async function verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, optio
860
804
  eventBytesMalformed = true;
861
805
  evErrors.push(`event event_bytes is not hex${thrownDetail(e)}`);
862
806
  }
863
- const duplicate = recordDuplicateKey(eventBytes);
864
- if (duplicate !== undefined) {
807
+ const notJson = recordNotJson(eventBytes);
808
+ if (notJson !== undefined) {
865
809
  eventBytesMalformed = true;
866
- evErrors.push(`event record is not JSON: ${duplicate}`);
810
+ evErrors.push(notJson);
867
811
  }
868
812
  try {
869
813
  const res = await verifyEnvelope(ev.envelope, eventBytes !== undefined ? { payloadBytes: eventBytes } : {});
870
814
  const hexError = envelopeHexError("event", ev.envelope);
871
- // ok tolerates an undetermined binding (withheld record)
815
+ // Tolerates an undetermined binding (withheld record).
872
816
  envelopeOk = res.ok && hexError === null;
817
+ envelopeFaults = eventEnvelopeFaults(res.faults, hexError);
873
818
  payloadBindingOk = res.payloadHashOk;
874
- // P-10: report what could not be determined, never a fault that was never
875
- // observed. There is no envelope here, so no signature was checked and no
876
- // binding was attempted. "signature failed" named a check that never ran,
877
- // and it was the only thing said about an event whose undetermined
878
- // binding then rendered as a WITHHELD record, a claim about the
879
- // producer's disclosure choice out of an input that says nothing about
880
- // the producer at all (proof#76).
819
+ // No envelope, so no signature was checked: report it as malformed, not as
820
+ // a failed signature or a withheld record.
881
821
  if (!isJsonObject(ev.envelope)) {
882
822
  evErrors.push("event envelope is not a JSON object");
883
823
  }
@@ -889,11 +829,12 @@ async function verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, optio
889
829
  }
890
830
  if (res.payloadHashOk === false)
891
831
  evErrors.push("event record does not bind to payload_hash");
892
- // A malformed skeleton field fails the envelope with a valid signature
893
- // and binding (proof#95), so without this the event fails with no reason.
832
+ // Name a malformed skeleton field, which fails an otherwise valid envelope.
894
833
  for (const m of res.errors)
895
834
  if (m.startsWith("malformed "))
896
835
  evErrors.push(`event envelope ${m}`);
836
+ if (res.errors.some((m) => m.startsWith("missing payload_hash")))
837
+ evErrors.push("event envelope missing payload_hash");
897
838
  const specFinding = specVersionFinding("event", ev.envelope, res);
898
839
  if (specFinding !== null)
899
840
  evErrors.push(specFinding);
@@ -901,18 +842,18 @@ async function verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, optio
901
842
  catch (e) {
902
843
  evErrors.push(`event envelope verify error${thrownDetail(e)}`);
903
844
  }
904
- // Bind the event to exactly one checkpoint in the bundle, then verify
905
- // RFC 9162 inclusion against THAT checkpoint's merkle_root.
845
+ // Bind the event to exactly one checkpoint, then verify RFC 9162 inclusion
846
+ // against that checkpoint's merkle_root.
906
847
  const incl = ev.inclusion;
907
- // An inclusion carrying the retired `checkpoint_tx_id` spelling is a mixed
908
- // bundle, and resolving it anyway is how an event silently binds to a
909
- // checkpoint nobody meant.
848
+ // The retired `checkpoint_tx_id` spelling marks a mixed bundle; resolving
849
+ // it could bind the event to an unintended checkpoint.
910
850
  const keyField = "checkpoint_ref";
911
851
  const wrongField = "checkpoint_tx_id";
912
852
  const inclAsRecord = incl;
913
853
  const rawKey = inclAsRecord ? inclAsRecord[keyField] : undefined;
914
854
  const strayKey = inclAsRecord ? inclAsRecord[wrongField] : undefined;
915
855
  let cp;
856
+ let stampAbsent = false;
916
857
  if (!incl || typeof incl !== "object") {
917
858
  evErrors.push("event missing inclusion proof");
918
859
  }
@@ -924,28 +865,24 @@ async function verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, optio
924
865
  }
925
866
  else {
926
867
  cp = byKey.get(rawKey);
927
- if (cp === undefined) {
868
+ if (cp === undefined && !heldRefs.has(rawKey.toLowerCase())) {
869
+ // The pack does not hold the checkpoint the record names: `witnessed` is not
870
+ // proven (`missing`).
871
+ stampAbsent = true;
872
+ evErrors.push(`event inclusion.${keyField} ${JSON.stringify(rawKey)} names a checkpoint this pack does not hold`);
873
+ }
874
+ else if (cp === undefined) {
928
875
  evErrors.push(`event inclusion.${keyField} ${JSON.stringify(rawKey)} resolves to no checkpoint`);
929
876
  }
930
877
  }
931
878
  const checkpointBound = cp !== undefined;
932
879
  let inclusionOk = false;
933
- // §5.1, A38 (proof#197): the tree walked must be the tree the checkpoint
934
- // record signed, the same rule and the same sentence as the stamp path's
935
- // rule 3. RFC 9162's walk reads one audit path as valid at more than one
936
- // (leaf_index, leaf_count), so without this a real proof at (2, 3) also
937
- // verified as (1, 2) or (4, 5). A signed count that cannot be read is not
938
- // compared here: it has already failed its checkpoint (above), so the
939
- // bundle cannot verify. One reader of the signed count, shared with the
940
- // completeness notes, so the verdict and the grade never read two
941
- // different numbers.
942
- //
943
- // Every number here is read BY VALUE (`Number.isSafeInteger`), the model
944
- // JCS signs, and the Python kernel reads the same way (`_safe_integer`):
945
- // JCS writes `3.0` as `3`, so a stated `3.0` is the integer 3 under the
946
- // same wrapper signature. A value that is not a safe integer is malformed,
947
- // with one sentence that prints no value, because the two kernels cannot
948
- // print `2.5`, `1e400` or `9007199254740993` the same way.
880
+ // The tree walked must be the tree the checkpoint record signed (§5.1): an
881
+ // RFC 9162 audit path is valid at more than one (leaf_index, leaf_count),
882
+ // so a proof at (2, 3) would also verify as (1, 2). An unreadable signed
883
+ // count already failed its checkpoint. Numbers are read by value, as JCS
884
+ // signs them; a non-safe-integer is malformed, in a sentence that prints no
885
+ // value because the kernels cannot print it alike.
949
886
  const signedCount = cp ? signedLeafCount(cp) : null;
950
887
  if (incl && checkpointBound && cp) {
951
888
  const statedCount = safeInteger(incl.leaf_count);
@@ -963,18 +900,13 @@ async function verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, optio
963
900
  else {
964
901
  try {
965
902
  inclusionOk = await verifyInclusion(hexToBytes(incl.leaf_hash), statedIndex, statedCount, (incl.audit_path ?? []).map((h) => hexToBytes(h)),
966
- // No `?? ""` fallback. A checkpoint that claims NO root must not be
967
- // handed an invented empty one: the Python kernel refuses the same
968
- // value and reports the decode failure, so substituting "" here had
969
- // the two kernels say different things about one bundle (TS: "the
970
- // proof did not reconstruct the checkpoint root", which is not what
971
- // happened: there was no claimed root to reconstruct).
903
+ // No `?? ""` fallback: a checkpoint claiming no root must not be
904
+ // handed an invented one.
972
905
  hexToBytes(cp.merkle_root));
973
906
  if (!inclusionOk)
974
907
  evErrors.push("RFC 9162 inclusion proof did not reconstruct the checkpoint root");
975
- // Defense in depth: the claimed leaf_hash MUST be the hash of the
976
- // event's own signed envelope bytes (profile §6) — otherwise a valid
977
- // inclusion proof for an UNRELATED leaf could be smuggled in.
908
+ // The claimed leaf_hash must hash the event's own signed envelope
909
+ // (profile §6), or a proof for an unrelated leaf could be smuggled in.
978
910
  const expectedLeaf = await leafHash(utf8(jcs(ev.envelope)));
979
911
  const expectedLeafHex = toHex(expectedLeaf);
980
912
  if (expectedLeafHex !== (incl.leaf_hash ?? "").toLowerCase()) {
@@ -987,10 +919,10 @@ async function verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, optio
987
919
  }
988
920
  }
989
921
  }
990
- // Content (raw-log) binding — shared with every other event-carrying body.
991
922
  const contentRes = await verifyDisclosedContent(ev, eventId, eventBytes, options.content);
992
923
  const contentOk = contentRes.contentOk;
993
924
  evErrors.push(...contentRes.errors);
925
+ agentItems.push(agentItem(ev, eventBytes, envelopeOk && payloadBindingOk === true && !eventBytesMalformed));
994
926
  eventResults.push({
995
927
  eventId,
996
928
  envelopeOk,
@@ -998,13 +930,9 @@ async function verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, optio
998
930
  inclusionOk,
999
931
  checkpointBound,
1000
932
  contentOk,
1001
- // An event is "ok" when its envelope is authentic (signature + binding
1002
- // not-failed), its inclusion proof reconstructs the root, it binds to a
1003
- // present checkpoint, and its disclosed content did not MISMATCH. A
1004
- // withheld record or undisclosed content (binding/content null) does not
1005
- // fail it.
1006
- // `!eventBytesMalformed`: present-and-unreadable record bytes are a
1007
- // defect in the artifact, never a disclosure choice (proof#85).
933
+ // Ok when the envelope is authentic, inclusion reconstructs the root, the
934
+ // checkpoint is present, and disclosed content did not mismatch. Withheld
935
+ // or undisclosed content does not fail it; unreadable record bytes do.
1008
936
  ok: envelopeOk &&
1009
937
  inclusionOk &&
1010
938
  checkpointBound &&
@@ -1012,15 +940,22 @@ async function verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, optio
1012
940
  legacyError === null &&
1013
941
  !eventBytesMalformed,
1014
942
  errors: evErrors,
943
+ agent: null,
944
+ envelopeFault: envelopeFaults[0] ?? null,
945
+ envelopeFaults,
946
+ recordNotJson: notJson !== undefined && envelopeOk && payloadBindingOk === true,
947
+ contentUnsigned: contentOk === false && contentRes.unsigned,
948
+ stampAbsent,
949
+ ownFormFailed: legacyError !== null || eventBytesMalformed,
1015
950
  });
1016
951
  }
1017
- // --- Step 5: optional on-chain re-fetch ------------------------------------
952
+ await attachAgentReadings(eventResults, agentItems);
953
+ // Step 5: optional on-chain re-fetch.
1018
954
  const fetchImpl = options.fetchImpl ?? globalThis.fetch;
1019
955
  let gatewayUnavailable = false;
1020
956
  let uncheckedKind = false;
1021
957
  let uncheckedId = false;
1022
- // Simulated witnesses against witnesses of any other kind, counted over the
1023
- // checkpoints that name one, as `refetchStamps` counts stamps.
958
+ // Simulated witnesses against any other kind, as `refetchStamps` counts.
1024
959
  let simulated = 0;
1025
960
  let onChainKinds = 0;
1026
961
  if (onChainChecked) {
@@ -1033,21 +968,11 @@ async function verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, optio
1033
968
  const cp = byKey.get(cpr.checkpointRef);
1034
969
  if (!cp)
1035
970
  continue;
1036
- // A checkpoint with no anchor_ref has NOTHING to re-fetch. Under DL-52
1037
- // that is the normal state inside a stamp interval — placed, readable,
1038
- // not yet witnessed — so it reports as unreachable (which downgrades
1039
- // the run to partial) and NEVER as a mismatch. Treating "we never
1040
- // anchored it yet" as "the chain disagrees" would be the same false
1041
- // accusation this whole change is removing.
1042
- // proof#120. An `anchor_ref` that failed structural validation is not
1043
- // an absent one. Nothing was fetched, so the witness dimension is NOT
1044
- // CHECKED — `onChain: null`, exactly as when the caller supplied no
1045
- // adapter — and it must not raise `gatewayUnavailable`, which is what
1046
- // puts "could not be re-fetched from the supplied gateways" on the
1047
- // bundle. Reporting an outage nobody observed is a claim about the
1048
- // public record made out of a defect in the artifact, and verify-page
1049
- // renders it word for word. The run still fails: the structural error
1050
- // is already in `cpr.errors` and already in `ok` above.
971
+ // No `anchor_ref`: nothing to re-fetch. Absent (placed, not yet
972
+ // witnessed) reports as unreachable, downgrading the run to partial and
973
+ // never to a mismatch. Malformed reports as not checked (`onChain:
974
+ // null`) and must not raise `gatewayUnavailable`: no outage was
975
+ // observed, and the structural error already fails the run.
1051
976
  if (cpr.anchorRef === null && malformedAnchorRef.has(cpr)) {
1052
977
  cpr.onChain = null;
1053
978
  cpr.onChainOk = null;
@@ -1079,55 +1004,47 @@ async function verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, optio
1079
1004
  uncheckedId = true;
1080
1005
  if (outcome.error)
1081
1006
  cpr.errors.push(outcome.error);
1082
- // Fold the on-chain dimension in: a MISMATCH (false) fails the
1083
- // checkpoint — the proof was not anchored as claimed. An UNREACHABLE
1084
- // gateway (null) leaves the offline-sound checkpoint ok; the run
1085
- // downgrades to "partial" (exit 3) via gatewayUnavailable, not a hard
1086
- // failure. A match (true) keeps it ok.
1087
- // AND into the verdict already computed, never rebuild it from a
1088
- // hand-listed set of dimensions. Rebuilding silently dropped every
1089
- // term not in that list: the duplicate-key gate (proof#28) and the
1090
- // `keyed.errors` term with it, so supplying a gateway turned a hard
1091
- // reject back into `verified` and an UNREACHABLE gateway turned it
1092
- // into `partial` (exit 3), which reads as a network problem an
1093
- // operator retries. Neither is conditioned on a gateway by the spec:
1094
- // `evidence-bundle.md` says two checkpoints carrying one key is a
1095
- // hard reject, full stop. Written this way, a term added at
1096
- // construction survives here by construction.
1007
+ // A mismatch fails the checkpoint; unreachable leaves it ok and
1008
+ // downgrades the run to partial. AND into the verdict already computed,
1009
+ // never rebuild it, so every construction-time term (the duplicate-key
1010
+ // gate included) survives.
1097
1011
  cpr.ok = cpr.ok && cpr.onChainOk !== false;
1098
1012
  }
1099
1013
  }
1100
1014
  }
1101
- // --- Rollup ----------------------------------------------------------------
1015
+ // Rollup.
1102
1016
  const allCheckpointsOk = checkpointResults.length > 0 && checkpointResults.every((c) => c.ok);
1103
1017
  const allEventsOk = eventResults.every((e) => e.ok);
1104
- const anyHardFailure = checkpointResults.some((c) => !c.ok) || eventResults.some((e) => !e.ok);
1018
+ const anyHardFailure = checkpointResults.some((c) => !c.ok) || eventResults.some((e) => !e.ok && !unproven(e));
1105
1019
  const anyWithheldRecord = eventResults.some((e) => e.payloadBindingOk === null);
1020
+ const anyStampAbsent = eventResults.some((e) => e.stampAbsent === true);
1021
+ // A lookup that did not settle is the witness line's to say, never the status's: the
1022
+ // witness is proven offline by the checkpoint and its inclusion proof, and only a lookup
1023
+ // that finds other bytes (a hard failure above) moves anything.
1024
+ pushRefetchGapNotes(errors, "checkpoints", {
1025
+ unavailable: gatewayUnavailable,
1026
+ kind: uncheckedKind,
1027
+ id: uncheckedId,
1028
+ });
1029
+ // A checkpoint nobody has witnessed yet (no anchor_ref) is placed, not witnessed: the
1030
+ // `witnessed` claim is not proven (evidence-bundle.md section 3), as for a stamp.
1031
+ const anyUnwitnessed = checkpointResults.some((c) => c.anchorRef === null);
1106
1032
  let status;
1107
1033
  if (anyHardFailure) {
1108
1034
  status = "failed";
1109
1035
  }
1110
- else if (gatewayUnavailable || uncheckedKind || uncheckedId) {
1111
- // Every offline check passed and nothing MISMATCHED, but a requested
1112
- // on-chain confirmation could not be made — the bundle is offline-sound,
1113
- // its anchored state unconfirmed. "partial" + a note the CLI maps to exit 3.
1114
- status = "partial";
1115
- pushRefetchGapNotes(errors, "checkpoints", {
1116
- unavailable: gatewayUnavailable,
1117
- kind: uncheckedKind,
1118
- id: uncheckedId,
1119
- });
1036
+ else if (anyUnwitnessed || anyStampAbsent || eventResults.some(unproven)) {
1037
+ status = "incomplete";
1120
1038
  }
1121
1039
  else if (anyWithheldRecord) {
1122
- // Everything that COULD be checked passed; some record bytes were withheld
1123
- // so their semantic binding is undetermined (not a failure) — "partial".
1124
- status = "partial";
1040
+ // Some record bytes were withheld, so their binding is not checked.
1041
+ status = "incomplete";
1125
1042
  }
1126
1043
  else if (allCheckpointsOk && allEventsOk) {
1127
1044
  status = "verified";
1128
1045
  }
1129
1046
  else {
1130
- status = "partial";
1047
+ status = "incomplete";
1131
1048
  }
1132
1049
  return {
1133
1050
  status,
@@ -1135,60 +1052,20 @@ async function verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, optio
1135
1052
  bodyHashOk: true,
1136
1053
  signatureOk: true,
1137
1054
  bodyType,
1138
- // A run whose only witnessed checkpoints are simulated looked nothing up
1139
- // on a chain, so it is not counted as checked on one (the stamp rule in
1140
- // `refetchStamps`). The checkpoints stay not checked and the run partial.
1055
+ // Simulated-only witnesses looked nothing up on a chain, so the run is not
1056
+ // counted as checked on one.
1141
1057
  onChainChecked: onChainChecked && !(simulated > 0 && onChainKinds === 0),
1142
1058
  checkpoints: checkpointResults,
1143
1059
  events: eventResults,
1144
- assertedStatus,
1145
1060
  errors,
1146
1061
  };
1147
1062
  }
1148
- // ===========================================================================
1149
- // `sanning.stamp.trace/v1` — the Sanning-signed interval stamp (§5.2, DL-58)
1150
- //
1151
- // ── What this can prove, and what it deliberately does not claim ──
1152
- //
1153
- // A stamp is not a producer artifact. Under DL-52 the customer signs an
1154
- // envelope and POSTs it; the commitment is witnessed later, on an interval, by
1155
- // a stamp the customer never sees and holds no key for. So there is no
1156
- // producer signature inside this body to check, and — this is the part worth
1157
- // being precise about — there is no SECOND Sanning signature either. The
1158
- // control plane stores the stamp's canonical bytes and hands them to a witness
1159
- // as a signed chain data item; the signature lives on the chain, not in the
1160
- // `stamps` table and not in this body.
1161
- //
1162
- // What "Sanning signed this" means for a stamp bundle is therefore exactly the
1163
- // WRAPPER signature, verified generically before this function is reached. And
1164
- // what makes the stamp evidence rather than an assertion is the witness, which
1165
- // is on a chain and not checkable offline. Hence the rollup below: a stamp we
1166
- // can read but nobody has witnessed holds the bundle at `partial`.
1167
- //
1168
- // So the checkable surface here is the stamp's INTERNAL consistency against
1169
- // the one authority in the bundle — the witnessed bytes:
1170
- //
1171
- // 1. `stamp_ref` is SHA-256 of the DECODED bytes (derived, not asserted).
1172
- // 2. Those bytes parse and re-canonicalize to themselves under JCS.
1173
- // 3. Their `stamp_version` is the one this body_type pins.
1174
- // 4. Every sub-tree range accounts for exactly one leaf of the stamp.
1175
- //
1176
- // (4) is the one that carries customer weight. R-8's whole per-agent story is
1177
- // "prove this agent was under evidence over window W", answered by a
1178
- // contiguous `first_leaf`/`leaf_count` slice of the org tree. Ranges that do
1179
- // not tile `[0, leaf_count)` describe a partition that does not exist, so a
1180
- // slice claim resting on them is unfounded — and unlike a bad signature,
1181
- // nothing else in the stack would ever notice.
1182
- // ===========================================================================
1183
- // A JSON object, as opposed to null, an array, a string or a number. Used at
1184
- // every point where a nested field MUST be an object before any check of it
1185
- // means anything: probing a verifier with a non-object asks a question about
1186
- // something that was never the thing, and the answer must say so rather than
1187
- // name a check that never ran (proof#76).
1063
+ // A JSON object, as opposed to null, an array, a string or a number. A nested
1064
+ // field must be one before any check of it means anything.
1188
1065
  function isJsonObject(v) {
1189
1066
  return typeof v === "object" && v !== null && !Array.isArray(v);
1190
1067
  }
1191
- /** Lowercase hex, even length, non-empty. The family's serialization rule. */
1068
+ /** Lowercase hex, even length, non-empty. */
1192
1069
  function isLowerHex(s, nibbles) {
1193
1070
  if (typeof s !== "string" || s.length === 0 || s.length % 2 !== 0)
1194
1071
  return false;
@@ -1197,54 +1074,29 @@ function isLowerHex(s, nibbles) {
1197
1074
  return /^[0-9a-f]+$/.test(s);
1198
1075
  }
1199
1076
  /**
1200
- * A JSON number read BY VALUE as a non-negative safe integer, or null.
1201
- * `safeInteger`, the reader every leaf count and leaf index in this kernel
1202
- * goes through (A38), mirrored by `_non_neg_integer` in
1203
- * `src/sanning_proof/evidence.py`. `Number.isInteger` alone accepted `1e+21`,
1204
- * JCS's own spelling of a count past 2**53 - 1, which Python refused.
1077
+ * A JSON number read by value as a non-negative safe integer, or null. Matches
1078
+ * Python's `_non_neg_integer`; `Number.isInteger` alone would accept `1e+21`.
1205
1079
  */
1206
1080
  function nonNegInteger(n) {
1207
1081
  const v = safeInteger(n);
1208
1082
  return v !== null && v >= 0 ? v : null;
1209
1083
  }
1210
- /**
1211
- * Parse and check one stamp's witnessed bytes.
1212
- *
1213
- * Errors accumulate rather than short-circuit wherever a later check is still
1214
- * meaningful, so a bundle with two independent defects reports two — a
1215
- * verifier that stops at the first one turns a second fix into a second
1216
- * round-trip.
1217
- */
1218
- // ===========================================================================
1219
- // Disclosed-content binding, shared by every body that carries events.
1220
- //
1221
- // Extracted for `sanning.stamp.evidence/v2` (§5.3) rather than copied. The
1222
- // rawLog -> content_hash link is the one step the chain
1223
- // (content_hash -> record -> payload_hash -> signature -> leaf -> root) does
1224
- // not otherwise cover, and a second copy of it is a second place for the
1225
- // never-fail-on-absent discipline to drift. The rules are unchanged from the
1226
- // anchor-trace path this came from: undetermined (null) when nothing is
1227
- // disclosed or nothing is committed; false ONLY on a real mismatch or an
1228
- // in-body vs side-input disagreement.
1229
- // ===========================================================================
1084
+ // Disclosed-content binding, shared by every body that carries events. The
1085
+ // raw log to `content_hash` link is the one step the signature chain does not
1086
+ // cover. Undetermined (null) when nothing is disclosed or committed; false only
1087
+ // on a real mismatch or an in-body vs side-input disagreement.
1230
1088
  async function verifyDisclosedContent(ev, eventId, eventBytes, sideContent) {
1231
1089
  const evErrors = [];
1232
1090
  let contentOk = null;
1233
- // WHICH hash in the record is decided by the record's STRUCTURE
1234
- // (evidence-bundle §5.1 v1.19, A20). A record `envelope-spec.md` §4.1
1235
- // governs (`isHandoffNamespace`) commits to its message through
1236
- // `event.message_hash`, SHA-256 of `JCS(message object)`, and has no
1237
- // `content_hash`, so until A20 its disclosure bound to nothing and an honest
1238
- // hand-off pack could never grade `verified`. For such a record the committed
1239
- // hash is `event.message_hash` and nothing else: not a `content_hash` it also
1240
- // carries, and not the envelope's. It binds only when the record is a
1241
- // CONFORMANT hand-off record under the key that signed it
1242
- // (`isConformantHandoffRecord`) and the value is 64 characters of lowercase
1243
- // hex. Anything short of that commits nothing this reader will bind to, so
1244
- // the reading is undetermined and never true: a record dressed as a hand-off
1245
- // record gains no binding from the dress, and a record outside §4.1 keeps its
1246
- // `content_hash` exactly as before, whatever `message_hash` field it writes.
1247
- // The Python twin is `evidence._evaluate_content`.
1091
+ // Whether a `false` rests on side bytes nobody signed: the side input checked alone, or
1092
+ // side bytes that differ from in-body bytes that do not fail on their own.
1093
+ let unsigned = false;
1094
+ // Which hash binds depends on the record's structure (§5.1). A record §4.1
1095
+ // governs commits through `event.message_hash` (SHA-256 of the message's
1096
+ // JCS), and binds only when it is a conformant hand-off record and the value
1097
+ // is 64 lowercase hex characters; otherwise the reading is undetermined, never
1098
+ // true. Any other record binds through `content_hash`. Python's twin is
1099
+ // `_evaluate_content`.
1248
1100
  const committed = [];
1249
1101
  let recordContentHash;
1250
1102
  let governed = false;
@@ -1277,8 +1129,8 @@ async function verifyDisclosedContent(ev, eventId, eventBytes, sideContent) {
1277
1129
  }
1278
1130
  }
1279
1131
  catch {
1280
- // record not JSON — the payload binding already reflects that; content
1281
- // stays undetermined rather than throwing.
1132
+ // Record not JSON: the payload binding reflects that; content stays
1133
+ // undetermined.
1282
1134
  }
1283
1135
  }
1284
1136
  if (governed) {
@@ -1289,9 +1141,8 @@ async function verifyDisclosedContent(ev, eventId, eventBytes, sideContent) {
1289
1141
  committed.push(recordContentHash);
1290
1142
  }
1291
1143
  else if (ev.envelope && typeof ev.envelope === "object") {
1292
- // Promoted fallback. Guarded: contentHashes() dereferences env.payload,
1293
- // which throws on a null/non-object envelope — a verifier must never raise
1294
- // on adversarial input.
1144
+ // Promoted fallback, guarded: `contentHashes` would throw on a non-object
1145
+ // envelope.
1295
1146
  for (const c of contentHashes(ev.envelope))
1296
1147
  committed.push(c.hash.toLowerCase());
1297
1148
  }
@@ -1301,6 +1152,8 @@ async function verifyDisclosedContent(ev, eventId, eventBytes, sideContent) {
1301
1152
  if (inBody !== undefined && side !== undefined && !bytesEqual(inBody, side)) {
1302
1153
  contentOk = false;
1303
1154
  evErrors.push("disclosed content disagreement: in-body events[].content and the supplied content differ");
1155
+ // Unsigned unless the in-body bytes, which the wrapper signs, fail on their own.
1156
+ unsigned = committed.length === 0 || committed.includes(await sha256Hex(inBody));
1304
1157
  }
1305
1158
  else {
1306
1159
  const disclosed = inBody ?? side;
@@ -1310,14 +1163,15 @@ async function verifyDisclosedContent(ev, eventId, eventBytes, sideContent) {
1310
1163
  evErrors.push(governed
1311
1164
  ? "disclosed content present but this hand-off record commits no " +
1312
1165
  "message_hash a reader can bind to (not a conformant hand-off " +
1313
- "record, or message_hash is not 64 lowercase hex) -- undetermined"
1166
+ "record, or message_hash is not 64 lowercase hex) -- not checked"
1314
1167
  : "disclosed content present but no committed content_hash to bind to " +
1315
- "(record withheld or record has no event.content_hash) -- undetermined");
1168
+ "(record withheld or record has no event.content_hash) -- not checked");
1316
1169
  }
1317
1170
  }
1318
1171
  else {
1319
1172
  const got = await sha256Hex(disclosed);
1320
1173
  contentOk = committed.includes(got);
1174
+ unsigned = !contentOk && inBody === undefined;
1321
1175
  if (!contentOk) {
1322
1176
  const fieldName = governed ? "message_hash" : "content_hash";
1323
1177
  evErrors.push(`disclosed content does not match the committed ${fieldName}: ` +
@@ -1325,8 +1179,12 @@ async function verifyDisclosedContent(ev, eventId, eventBytes, sideContent) {
1325
1179
  }
1326
1180
  }
1327
1181
  }
1328
- return { contentOk, errors: evErrors };
1182
+ return { contentOk, unsigned, errors: evErrors };
1329
1183
  }
1184
+ /**
1185
+ * Parse and check one stamp's witnessed bytes. Errors accumulate wherever a
1186
+ * later check still means something, so independent defects are all reported.
1187
+ */
1330
1188
  async function verifyStampEntry(raw, seen) {
1331
1189
  const errors = [];
1332
1190
  const base = {
@@ -1351,7 +1209,7 @@ async function verifyStampEntry(raw, seen) {
1351
1209
  return { result: base };
1352
1210
  }
1353
1211
  const e = raw;
1354
- // --- the witnessed bytes, which are the only authority in this body -------
1212
+ // The witnessed bytes are the only authority in this body.
1355
1213
  if (!isLowerHex(e.stamp_bytes)) {
1356
1214
  errors.push("stamp_bytes is missing or not lowercase hex");
1357
1215
  return { result: base };
@@ -1364,19 +1222,14 @@ async function verifyStampEntry(raw, seen) {
1364
1222
  errors.push(`stamp_bytes is not hex${thrownDetail(err)}`);
1365
1223
  return { result: base };
1366
1224
  }
1367
- // Gated by `isLowerHex` above, so this is already the exact lowercase hex of
1368
- // the exact witnessed bytes — §5.2.2's "one authority in this body".
1225
+ // Gated by `isLowerHex` above: already the exact witnessed bytes' hex (§5.2.2).
1369
1226
  const witnessedHex = e.stamp_bytes;
1370
- // --- the derived identity -------------------------------------------------
1371
1227
  const derived = await sha256Hex(bytes);
1372
1228
  if (typeof e.stamp_ref !== "string" || e.stamp_ref === "") {
1373
1229
  errors.push("stamp entry missing stamp_ref");
1374
1230
  }
1375
1231
  else if (e.stamp_ref.toLowerCase() !== derived) {
1376
- // The bytes were edited and the label was not — or the label was edited
1377
- // and the bytes were not. Either way the bundle names a stamp it is not
1378
- // carrying, and quoting that name to a witness would resolve to something
1379
- // else entirely.
1232
+ // Bytes and label disagree: the bundle names a stamp it is not carrying.
1380
1233
  errors.push(`stamp_ref does not match SHA-256(stamp_bytes): claimed=${e.stamp_ref} derived=${derived}`);
1381
1234
  }
1382
1235
  else {
@@ -1390,7 +1243,6 @@ async function verifyStampEntry(raw, seen) {
1390
1243
  seen.add(derived);
1391
1244
  }
1392
1245
  }
1393
- // --- the body itself ------------------------------------------------------
1394
1246
  let body;
1395
1247
  try {
1396
1248
  const parsed = jcsParse(bytes);
@@ -1399,10 +1251,8 @@ async function verifyStampEntry(raw, seen) {
1399
1251
  return { result: base, witnessedHex };
1400
1252
  }
1401
1253
  body = parsed;
1402
- // Re-canonicalize and compare. The plane replays these bytes verbatim on
1403
- // every submit attempt for exactly this reason: a non-canonical body makes
1404
- // a retry a DIFFERENT submission, and makes every independent re-derivation
1405
- // of the stamp disagree with the one that was witnessed.
1254
+ // Must re-canonicalize to itself: a non-canonical body makes every
1255
+ // independent re-derivation disagree with what was witnessed.
1406
1256
  if (jcs(body) !== decodeUtf8Strict(bytes)) {
1407
1257
  errors.push("stamp_bytes are not JCS-canonical (RFC 8785)");
1408
1258
  return { result: base, witnessedHex };
@@ -1413,10 +1263,8 @@ async function verifyStampEntry(raw, seen) {
1413
1263
  return { result: base, witnessedHex };
1414
1264
  }
1415
1265
  if (body.stamp_version !== STAMP_BODY_VERSION) {
1416
- // A `sanning.stamp.trace/v1` body whose inner version is something else is
1417
- // two answers to one question inside one signature. The body_type is the
1418
- // reader's dispatch key and it said v1; honouring the other spelling is
1419
- // how a reader half-parses a shape it does not know.
1266
+ // The inner version must match the body type: two answers inside one
1267
+ // signature are rejected.
1420
1268
  errors.push(`unsupported stamp_version ${JSON.stringify(body.stamp_version ?? null)} in a ` +
1421
1269
  `${STAMP_TRACE_BODY_TYPE} body (expected ${STAMP_BODY_VERSION})`);
1422
1270
  return { result: base, witnessedHex };
@@ -1429,7 +1277,7 @@ async function verifyStampEntry(raw, seen) {
1429
1277
  base.bodyOk = false;
1430
1278
  }
1431
1279
  else if (f !== "org_ref" && !isRfc3339UtcInstant(body[f])) {
1432
- // §5.2 (A31): the promise window is the form `signed_at` pins.
1280
+ // The promise window uses the form `signed_at` pins (§5.2).
1433
1281
  errors.push(`stamp body ${f} is not an RFC 3339 UTC date-time with Z naming a real date and time`);
1434
1282
  base.bodyOk = false;
1435
1283
  }
@@ -1451,27 +1299,23 @@ async function verifyStampEntry(raw, seen) {
1451
1299
  else {
1452
1300
  base.leafCount = stampCount;
1453
1301
  }
1454
- // --- the sub-tree ranges --------------------------------------------------
1455
1302
  base.subtreesOk = checkSubtrees(body, base, errors);
1456
- // A stamp needs no malformed/absent split: the §5.2 re-fetch path already
1457
- // leaves `onChain` null whenever there is no `AnchorRef`, so it never
1458
- // synthesised the outage proof#120 is about.
1459
- base.anchorRef = parseAnchorRef(e.anchor_ref, errors).ref;
1303
+ // No malformed/absent split is needed: the re-fetch leaves `onChain` null
1304
+ // whenever there is no `AnchorRef`.
1305
+ const clean = errors.length === 0;
1306
+ const parsedRef = parseAnchorRef(e.anchor_ref, errors);
1307
+ base.anchorRef = parsedRef.ref;
1460
1308
  base.ok =
1461
1309
  base.stampRefOk && base.bodyOk && base.subtreesOk && errors.length === 0;
1310
+ base.anchorRefMalformed =
1311
+ parsedRef.malformed && clean && base.stampRefOk && base.bodyOk && base.subtreesOk;
1462
1312
  return { result: base, witnessedHex };
1463
1313
  }
1464
1314
  /**
1465
- * The range arithmetic (§5.2), and the reason it is a hard check.
1466
- *
1467
- * `buildStampTree` orders a stamp's leaves agent-major, which is the only
1468
- * reason `first_leaf` + `leaf_count` can describe an agent's slice at all: the
1469
- * ranges partition `[0, leaf_count)` exactly, by construction. A body whose
1470
- * ranges do not is either not a stamp this stack built or one built wrong, and
1471
- * in both cases the per-agent claim the ranges exist to support is unfounded.
1472
- *
1473
- * The sum is checked BEFORE the tiling and reports its own message, so a body
1474
- * that is merely short does not get filed under a gap it does not have.
1315
+ * The range arithmetic (§5.2). Leaves are agent-major, so the ranges must
1316
+ * partition `[0, leaf_count)` exactly, or the per-agent slice claims they
1317
+ * support are unfounded. The sum is checked before the tiling so a short body
1318
+ * is not reported as a gap.
1475
1319
  */
1476
1320
  function checkSubtrees(body, out, errors) {
1477
1321
  const raw = body.subtrees;
@@ -1494,9 +1338,8 @@ function checkSubtrees(body, out, errors) {
1494
1338
  errors.push(`sub-tree ${ref} first_leaf is not a non-negative integer`);
1495
1339
  shapeOk = false;
1496
1340
  }
1497
- // A zero-leaf sub-tree is not a slice of anything, and admitting one lets
1498
- // an arbitrary number of empty ranges sit inside a tiling that still adds
1499
- // up.
1341
+ // A zero-leaf range is a slice of nothing, and empty ranges could pad a
1342
+ // tiling that still adds up.
1500
1343
  if (subCount === null || subCount === 0) {
1501
1344
  errors.push(`sub-tree ${ref} leaf_count is not a positive integer`);
1502
1345
  shapeOk = false;
@@ -1521,9 +1364,7 @@ function checkSubtrees(body, out, errors) {
1521
1364
  const total = out.leafCount;
1522
1365
  if (total === null)
1523
1366
  return false; // already reported by the caller
1524
- // Summed exactly. Each count is a safe integer but their sum need not be,
1525
- // and a float sum past 2**53 prints a different number than Python's
1526
- // unbounded one in the sentence below.
1367
+ // Summed exactly: the sum can pass 2**53 and must print as Python's does.
1527
1368
  const sum = subtrees.reduce((n, s) => n + BigInt(s.leafCount), 0n);
1528
1369
  if (sum !== BigInt(total)) {
1529
1370
  errors.push(`sub-tree leaf_count sum ${sum} does not equal the stamp's leaf_count ${total}`);
@@ -1540,7 +1381,19 @@ function checkSubtrees(body, out, errors) {
1540
1381
  }
1541
1382
  return true;
1542
1383
  }
1543
- async function verifyStampTrace(b, specVersionOk, assertedStatus, errors, options) {
1384
+ // `sanning.stamp.trace/v1`: the Sanning-signed interval stamp (§5.2).
1385
+ //
1386
+ // A stamp has no producer signature and no second Sanning signature in the
1387
+ // bundle: "Sanning signed this" is the wrapper signature, and the witness on a
1388
+ // chain is what makes it evidence. So an unwitnessed stamp holds the bundle at
1389
+ // `partial`. Offline, this checks the stamp against its witnessed bytes:
1390
+ //
1391
+ // 1. `stamp_ref` is SHA-256 of the decoded bytes.
1392
+ // 2. The bytes parse and re-canonicalize to themselves under JCS.
1393
+ // 3. Their `stamp_version` is the one this body type pins.
1394
+ // 4. The sub-tree ranges tile the stamp's leaves exactly: per-agent slice
1395
+ // claims rest on that, and nothing else in the stack would notice.
1396
+ async function verifyStampTrace(b, specVersionOk, errors, options) {
1544
1397
  const body = b.body;
1545
1398
  if (!Array.isArray(body?.stamps)) {
1546
1399
  return malformed(`${STAMP_TRACE_BODY_TYPE} body must have stamps[]`, true, true);
@@ -1553,46 +1406,34 @@ async function verifyStampTrace(b, specVersionOk, assertedStatus, errors, option
1553
1406
  stamps.push(outcome.result);
1554
1407
  witnessed.push(outcome.witnessedHex);
1555
1408
  }
1556
- // --- The optional on-chain re-fetch (§5.2.4's "online act") ---------------
1557
- //
1558
- // The SAME stamps[] array §5.3 carries, so it gets the same capability. The
1559
- // alternative — a stamp that can be re-fetched inside one body and not the
1560
- // other — asks every consumer to remember which wrapper it is holding before
1561
- // it can trust `onChain: null`, and that is exactly the kind of per-body
1562
- // exception a reader eventually gets wrong.
1409
+ // The optional on-chain re-fetch (§5.2.4), shared with §5.3 so `onChain:
1410
+ // null` means the same thing in both bodies.
1563
1411
  const { onChainChecked, gatewayUnavailable, uncheckedKind, uncheckedId } = await refetchStamps(stamps, witnessed, options, errors);
1564
- // --- Rollup ----------------------------------------------------------------
1412
+ // Rollup.
1565
1413
  const anyHardFailure = stamps.some((s) => !s.ok);
1566
1414
  const anyUnwitnessed = stamps.some((s) => s.anchorRef === null);
1415
+ // A lookup that did not settle is said here and moves nothing (the witness line says it).
1416
+ pushRefetchGapNotes(errors, "stamps", {
1417
+ unavailable: gatewayUnavailable,
1418
+ kind: uncheckedKind,
1419
+ id: uncheckedId,
1420
+ });
1567
1421
  let status;
1568
1422
  if (anyHardFailure) {
1569
1423
  status = "failed";
1570
1424
  }
1571
1425
  else if (stamps.length === 0) {
1572
- // A bundle asserting nothing. Not a failure — nothing here is wrong — but
1573
- // it must never read `verified`, which a caller would take as a witness it
1574
- // does not have.
1575
- status = "partial";
1426
+ // Asserts nothing: not a failure, but never `verified`.
1427
+ status = "incomplete";
1576
1428
  errors.push(`${STAMP_TRACE_BODY_TYPE} body carries no stamps`);
1577
1429
  }
1578
- else if (gatewayUnavailable || uncheckedKind || uncheckedId || anyUnwitnessed) {
1579
- // Both hold the bundle at `partial`, and both get said — see §5.3's rollup
1580
- // for why one note is not enough when there are two different gaps.
1581
- status = "partial";
1582
- pushRefetchGapNotes(errors, "stamps", {
1583
- unavailable: gatewayUnavailable,
1584
- kind: uncheckedKind,
1585
- id: uncheckedId,
1586
- });
1587
- if (anyUnwitnessed) {
1588
- // The honest half of P-10, one layer up. A stamp with no `anchor_ref` is
1589
- // sealed and unwitnessed: `retained-by-sanning`, which is a statement
1590
- // about our coverage and never an accusation about their evidence. It is
1591
- // also not `verified`, because for a stamp the witness is the entire
1592
- // independent part — unlike an anchor-trace checkpoint, which still
1593
- // carries a producer signature and an inclusion proof that stand on
1594
- // their own offline.
1595
- errors.push("one or more stamps carries no anchor_ref — sealed by Sanning, witnessed by nobody");
1430
+ else if (anyUnwitnessed) {
1431
+ status = "incomplete";
1432
+ {
1433
+ // A stamp with no `anchor_ref` is sealed and unwitnessed: a statement
1434
+ // about Sanning's coverage, never an accusation. Not `verified` either:
1435
+ // for a stamp the witness is the whole independent part.
1436
+ errors.push("one or more stamps carries no anchor_ref: sealed by Sanning, witnessed by nobody");
1596
1437
  }
1597
1438
  }
1598
1439
  else {
@@ -1607,32 +1448,21 @@ async function verifyStampTrace(b, specVersionOk, assertedStatus, errors, option
1607
1448
  onChainChecked,
1608
1449
  checkpoints: [],
1609
1450
  events: [],
1610
- assertedStatus,
1611
1451
  stamps,
1612
1452
  errors,
1613
1453
  };
1614
1454
  }
1615
- // ===========================================================================
1616
- // `sanning.stamp.evidence/v2`: the two halves, bound (evidence-bundle.md §5.3)
1617
- //
1618
- // The stamp half is §5.2's code unchanged (`verifyStampEntry`, which carries
1619
- // the sub-tree partition check). The event half reuses the same primitives the
1620
- // anchor-trace path uses — `verifyEnvelope`, `verifyInclusion`, `leafHash`,
1621
- // `verifyDisclosedContent` — because a second copy of Merkle verification is a
1622
- // second place for it to drift.
1623
- //
1624
- // What differs, and only this: the join. An event resolves `inclusion.stamp_ref`
1625
- // to a stamps[] entry and walks its audit path to the root committed INSIDE
1626
- // that stamp's witnessed bytes. There is no `merkle_root` field on the event to
1627
- // read instead — repeating the root beside its own authoritative bytes would be
1628
- // two answers to one question inside one signature (§5.1.2).
1629
- // ===========================================================================
1630
- async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, options) {
1455
+ // `sanning.stamp.evidence/v2`: both halves, bound (evidence-bundle.md §5.3).
1456
+ // The stamp half is `verifyStampEntry`; the event half reuses the anchor-trace
1457
+ // primitives. Only the join differs: an event resolves `inclusion.stamp_ref`
1458
+ // and walks to the root inside that stamp's witnessed bytes. There is no
1459
+ // `merkle_root` on the event, which would be a second answer (§5.1.2).
1460
+ async function verifyStampEvidence(b, specVersionOk, errors, options) {
1631
1461
  const body = b.body;
1632
1462
  if (!Array.isArray(body?.stamps) || !Array.isArray(body?.events)) {
1633
1463
  return malformed(`${STAMP_EVIDENCE_BODY_TYPE} body must have stamps[] and events[]`, true, true);
1634
1464
  }
1635
- // --- The stamp half: §5.2's rules, §5.2's code ----------------------------
1465
+ // The stamp half.
1636
1466
  const seen = new Set();
1637
1467
  const stamps = [];
1638
1468
  const witnessed = [];
@@ -1641,31 +1471,35 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
1641
1471
  stamps.push(outcome.result);
1642
1472
  witnessed.push(outcome.witnessedHex);
1643
1473
  }
1644
- // The join table. ONLY a stamp whose derived ref and witnessed bytes both
1645
- // checked out is joinable: a root read out of bytes that failed their own
1646
- // verification is not a root anyone should walk a proof to, and admitting one
1647
- // would let a broken stamp lend its authority to an event.
1474
+ // Only a stamp whose derived ref and witnessed bytes both checked out is
1475
+ // joinable: a failed stamp must not lend its root to an event.
1648
1476
  const byRef = new Map();
1649
1477
  for (const st of stamps) {
1650
1478
  if (st.stampRef && st.stampRefOk && st.bodyOk)
1651
1479
  byRef.set(st.stampRef, st);
1652
1480
  }
1653
- // --- The event half -------------------------------------------------------
1481
+ // Every ref a stamp entry claims or derives. A ref outside it names a stamp the pack
1482
+ // does not hold, which is a stamp missing, never a stamp proven wrong (section 5.3.3
1483
+ // rule 1).
1484
+ const heldRefs = new Set();
1485
+ for (const st of stamps)
1486
+ if (st.stampRef)
1487
+ heldRefs.add(st.stampRef);
1488
+ for (const raw of body.stamps) {
1489
+ if (raw !== null && typeof raw === "object" && !Array.isArray(raw)) {
1490
+ const claimed = raw.stamp_ref;
1491
+ if (typeof claimed === "string")
1492
+ heldRefs.add(claimed);
1493
+ }
1494
+ }
1495
+ // The event half.
1654
1496
  const eventResults = [];
1497
+ const agentItems = [];
1655
1498
  for (const rawEv of body.events) {
1656
1499
  const evErrors = [];
1657
- // A verifier must never RAISE on adversarial input — a caller handed a
1658
- // stack trace instead of a verdict cannot tell a malformed pack from a
1659
- // broken tool. `events[]` is attacker-shaped, so a null/non-object entry
1660
- // is coerced to an empty event and fails through the normal path, exactly
1661
- // as the Python kernel does. Found by adversarial review, not by the
1662
- // cross-kernel leg — whose cases did not include one, and now do.
1500
+ // `events[]` is attacker-shaped; a verifier never raises on it.
1663
1501
  if (rawEv === null || typeof rawEv !== "object" || Array.isArray(rawEv)) {
1664
- // NOT an event. Graded and reported as such rather than coerced to an
1665
- // empty one: probing verifyEnvelope with nothing asks the envelope layer
1666
- // a question about an object that was never an envelope, and the two
1667
- // kernels answer it differently (undetermined vs failed) — a real
1668
- // divergence, but not this body's to resolve. Reported separately.
1502
+ // Graded as not an event, never coerced to an empty one.
1669
1503
  eventResults.push({
1670
1504
  eventId: "<unknown>",
1671
1505
  envelopeOk: false,
@@ -1675,33 +1509,22 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
1675
1509
  contentOk: null,
1676
1510
  ok: false,
1677
1511
  errors: ["events[] entry is not an object"],
1512
+ agent: null,
1678
1513
  });
1514
+ agentItems.push(null);
1679
1515
  continue;
1680
1516
  }
1681
1517
  const ev = rawEv;
1682
1518
  const eventId = getEventId(ev.envelope);
1683
1519
  let envelopeOk = false;
1520
+ let envelopeFaults = [];
1684
1521
  let payloadBindingOk = null;
1685
- // A retired field name under this major is a reject, never an ignored
1686
- // unknown field: ignored, it reads as a withheld record (W-3).
1522
+ // A retired field name is a reject: ignored, it would read as withheld.
1687
1523
  const legacyError = legacyEventBytesError(ev, "event", STAMP_EVIDENCE_BODY_TYPE);
1688
1524
  if (legacyError !== null)
1689
1525
  evErrors.push(legacyError);
1690
- // MALFORMED IS A FAILURE; ABSENT IS UNDETERMINED (proof#85).
1691
- //
1692
- // `event_bytes` that will not decode leaves `eventBytes` undefined, and an
1693
- // undefined `eventBytes` is exactly how a WITHHELD record is spelled one
1694
- // line down. So an event carrying garbage in place of its record used to
1695
- // reach the same tri-state as an event carrying nothing: `payloadHashOk`
1696
- // null, `ok` true, a leading tick on the line, and a `verified` rollup over
1697
- // a record nobody could read. The only thing wrong with the pack was the
1698
- // bytes, and it was the one thing the verdict did not say.
1699
- //
1700
- // A record the producer chose not to disclose is the customer exercising
1701
- // minimum disclosure. A record that is present and unreadable is a defect
1702
- // in the artifact, and this kernel's standing rule (the same one
1703
- // `checkpoints[]` already applies through `keyed.errors`) is that a defect
1704
- // we can see fails.
1526
+ // Malformed is a failure; absent is undetermined. Undecodable `event_bytes`
1527
+ // would otherwise look exactly like a withheld record and verify.
1705
1528
  let eventBytes;
1706
1529
  let eventBytesMalformed = false;
1707
1530
  try {
@@ -1712,24 +1535,20 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
1712
1535
  eventBytesMalformed = true;
1713
1536
  evErrors.push(`event event_bytes is not hex${thrownDetail(e)}`);
1714
1537
  }
1715
- const duplicate = recordDuplicateKey(eventBytes);
1716
- if (duplicate !== undefined) {
1538
+ const notJson = recordNotJson(eventBytes);
1539
+ if (notJson !== undefined) {
1717
1540
  eventBytesMalformed = true;
1718
- evErrors.push(`event record is not JSON: ${duplicate}`);
1541
+ evErrors.push(notJson);
1719
1542
  }
1720
1543
  try {
1721
1544
  const res = await verifyEnvelope(ev.envelope, eventBytes !== undefined ? { payloadBytes: eventBytes } : {});
1722
1545
  const hexError = envelopeHexError("event", ev.envelope);
1723
- // tolerates an undetermined binding (withheld record)
1546
+ // Tolerates an undetermined binding (withheld record).
1724
1547
  envelopeOk = res.ok && hexError === null;
1548
+ envelopeFaults = eventEnvelopeFaults(res.faults, hexError);
1725
1549
  payloadBindingOk = res.payloadHashOk;
1726
- // P-10: report what could not be determined, never a fault that was never
1727
- // observed. There is no envelope here, so no signature was checked and no
1728
- // binding was attempted. "signature failed" named a check that never ran,
1729
- // and it was the only thing said about an event whose undetermined
1730
- // binding then rendered as a WITHHELD record, a claim about the
1731
- // producer's disclosure choice out of an input that says nothing about
1732
- // the producer at all (proof#76).
1550
+ // No envelope, so no signature was checked: report it as malformed, not as
1551
+ // a failed signature or a withheld record.
1733
1552
  if (!isJsonObject(ev.envelope)) {
1734
1553
  evErrors.push("event envelope is not a JSON object");
1735
1554
  }
@@ -1741,11 +1560,12 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
1741
1560
  }
1742
1561
  if (res.payloadHashOk === false)
1743
1562
  evErrors.push("event record does not bind to payload_hash");
1744
- // A malformed skeleton field fails the envelope with a valid signature
1745
- // and binding (proof#95), so without this the event fails with no reason.
1563
+ // Name a malformed skeleton field, which fails an otherwise valid envelope.
1746
1564
  for (const m of res.errors)
1747
1565
  if (m.startsWith("malformed "))
1748
1566
  evErrors.push(`event envelope ${m}`);
1567
+ if (res.errors.some((m) => m.startsWith("missing payload_hash")))
1568
+ evErrors.push("event envelope missing payload_hash");
1749
1569
  const specFinding = specVersionFinding("event", ev.envelope, res);
1750
1570
  if (specFinding !== null)
1751
1571
  evErrors.push(specFinding);
@@ -1753,16 +1573,15 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
1753
1573
  catch (e) {
1754
1574
  evErrors.push(`event envelope verify error${thrownDetail(e)}`);
1755
1575
  }
1756
- // --- The join (§5.3.3 rule 1) -------------------------------------------
1576
+ // The join (§5.3.3 rule 1).
1757
1577
  const incl = ev.inclusion;
1758
1578
  let st;
1579
+ let stampAbsent = false;
1759
1580
  if (!incl || typeof incl !== "object") {
1760
1581
  evErrors.push("event missing inclusion proof");
1761
1582
  }
1762
1583
  else if (incl.checkpoint_ref !== undefined || incl.checkpoint_tx_id !== undefined) {
1763
- // One bundle speaks one vocabulary. An inclusion carrying an anchor-trace
1764
- // join key inside this body is a mixed bundle, and resolving it anyway is
1765
- // how an event silently binds to something nobody meant (§5.1.2).
1584
+ // An anchor-trace join key inside this body is a mixed bundle (§5.1.2).
1766
1585
  evErrors.push(`event inclusion carries an anchor-trace join key in a ${STAMP_EVIDENCE_BODY_TYPE} body`);
1767
1586
  }
1768
1587
  else if (typeof incl.stamp_ref !== "string") {
@@ -1770,24 +1589,22 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
1770
1589
  }
1771
1590
  else {
1772
1591
  st = byRef.get(incl.stamp_ref);
1773
- if (st === undefined) {
1774
- // Hard reject, never undetermined: without a resolved stamp the
1775
- // inclusion proof has no root to check against at all.
1592
+ if (st === undefined && !heldRefs.has(incl.stamp_ref)) {
1593
+ // The pack does not hold the stamp the record names: the inclusion has no root to
1594
+ // check against, so `witnessed` is not proven (`missing`).
1595
+ stampAbsent = true;
1596
+ evErrors.push(`event inclusion.stamp_ref ${JSON.stringify(incl.stamp_ref)} names a stamp this pack does not hold`);
1597
+ }
1598
+ else if (st === undefined) {
1776
1599
  evErrors.push(`event inclusion.stamp_ref ${JSON.stringify(incl.stamp_ref)} resolves to no verified stamp`);
1777
1600
  }
1778
1601
  }
1779
1602
  const stampBound = st !== undefined;
1780
1603
  let inclusionOk = false;
1781
1604
  if (incl && stampBound && st) {
1782
- // Rule 3: the tree walked must be the tree the stamp committed to. A
1783
- // proof walked against a different leaf_count is a proof about a
1784
- // different tree, and RFC 9162's path shape depends on the size.
1785
- //
1786
- // Read BY VALUE, as the anchor-trace path reads them (A38), and as the
1787
- // Python kernel does (`_safe_integer`): the wrapper signs JCS(body),
1788
- // which writes `3.0` as `3`, so a stated `3.0` is 3 under the same
1789
- // signature. Anything that is not a safe integer is malformed, with a
1790
- // sentence that prints no value.
1605
+ // Rule 3: the tree walked must be the tree the stamp committed to; RFC
1606
+ // 9162's path shape depends on the size. Read by value, as JCS signs it
1607
+ // (`3.0` is 3); anything not a safe integer is malformed.
1791
1608
  const statedCount = safeInteger(incl.leaf_count);
1792
1609
  const statedIndex = safeInteger(incl.leaf_index);
1793
1610
  if (statedCount === null) {
@@ -1806,9 +1623,7 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
1806
1623
  if (!inclusionOk) {
1807
1624
  evErrors.push("RFC 9162 inclusion proof did not reconstruct the stamp root");
1808
1625
  }
1809
- // Defense in depth, as §5.1: the claimed leaf MUST be this event's
1810
- // own signed envelope bytes, or a valid proof for an UNRELATED leaf
1811
- // could be smuggled in.
1626
+ // As §5.1: the claimed leaf must be this event's own signed envelope.
1812
1627
  const expectedLeafHex = toHex(await leafHash(utf8(jcs(ev.envelope))));
1813
1628
  if (expectedLeafHex !== (incl.leaf_hash ?? "").toLowerCase()) {
1814
1629
  inclusionOk = false;
@@ -1823,16 +1638,16 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
1823
1638
  const contentRes = await verifyDisclosedContent(ev, eventId, eventBytes, options.content);
1824
1639
  const contentOk = contentRes.contentOk;
1825
1640
  evErrors.push(...contentRes.errors);
1641
+ agentItems.push(agentItem(ev, eventBytes, envelopeOk && payloadBindingOk === true && !eventBytesMalformed));
1826
1642
  eventResults.push({
1827
1643
  eventId,
1828
1644
  envelopeOk,
1829
1645
  payloadBindingOk,
1830
1646
  inclusionOk,
1831
- // Named for §5.1's join; here it reports whether `stamp_ref` resolved.
1647
+ // Here: whether `stamp_ref` resolved.
1832
1648
  checkpointBound: stampBound,
1833
1649
  contentOk,
1834
- // `!eventBytesMalformed`: present-and-unreadable record bytes are a
1835
- // defect in the artifact, never a disclosure choice (proof#85).
1650
+ // Unreadable record bytes are a defect, never a disclosure choice.
1836
1651
  ok: envelopeOk &&
1837
1652
  inclusionOk &&
1838
1653
  stampBound &&
@@ -1840,49 +1655,48 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
1840
1655
  legacyError === null &&
1841
1656
  !eventBytesMalformed,
1842
1657
  errors: evErrors,
1658
+ agent: null,
1659
+ envelopeFault: envelopeFaults[0] ?? null,
1660
+ envelopeFaults,
1661
+ recordNotJson: notJson !== undefined && envelopeOk && payloadBindingOk === true,
1662
+ contentUnsigned: contentOk === false && contentRes.unsigned,
1663
+ stampAbsent,
1664
+ ownFormFailed: legacyError !== null || eventBytesMalformed,
1843
1665
  });
1844
1666
  }
1845
- // --- The optional on-chain re-fetch (§5.2.4's "online act") ---------------
1667
+ await attachAgentReadings(eventResults, agentItems);
1668
+ // The optional on-chain re-fetch (§5.2.4).
1846
1669
  const { onChainChecked, gatewayUnavailable, uncheckedKind, uncheckedId } = await refetchStamps(stamps, witnessed, options, errors);
1847
- // --- Rollup ---------------------------------------------------------------
1848
- // Rule 4: a declared event MUST be checked. `events[]` present and unverified
1849
- // is the false green this body exists to prevent, so an event that did not
1850
- // verify fails the bundle — it is never rolled up as verified.
1851
- const anyHardFailure = stamps.some((st) => !st.ok) || eventResults.some((e) => !e.ok);
1670
+ // Rollup. Rule 4: a declared event must be checked, so an event that did
1671
+ // not verify fails the bundle.
1672
+ const anyHardFailure = stamps.some((st) => !st.ok) || eventResults.some((e) => !e.ok && !unproven(e));
1852
1673
  const anyUnwitnessed = stamps.some((st) => st.anchorRef === null);
1674
+ const anyStampAbsent = eventResults.some((e) => e.stampAbsent === true);
1675
+ pushRefetchGapNotes(errors, "stamps", {
1676
+ unavailable: gatewayUnavailable,
1677
+ kind: uncheckedKind,
1678
+ id: uncheckedId,
1679
+ });
1853
1680
  let status;
1854
1681
  if (anyHardFailure) {
1855
1682
  status = "failed";
1856
1683
  }
1857
1684
  else if (stamps.length === 0 && eventResults.length === 0) {
1858
- status = "partial";
1685
+ status = "incomplete";
1859
1686
  errors.push(`${STAMP_EVIDENCE_BODY_TYPE} body carries no stamps and no events`);
1860
1687
  }
1861
- else if (eventResults.length === 0 ||
1862
- gatewayUnavailable ||
1863
- uncheckedKind ||
1864
- uncheckedId ||
1865
- anyUnwitnessed) {
1866
- // Each holds the bundle at `partial`, and EACH gets said. A pack with no
1867
- // events, a stamp nobody witnessed and a witness we could not reach are
1868
- // different gaps with different fixes, and an auditor told only about one
1869
- // would read the others as fine. The order is fixed so the two kernels
1870
- // emit the same list.
1871
- status = "partial";
1688
+ else if (eventResults.length === 0 || anyUnwitnessed || anyStampAbsent || eventResults.some(unproven)) {
1689
+ // Each gap holds the bundle at `incomplete` and each is named, in a fixed order
1690
+ // so both kernels emit the same list.
1691
+ status = "incomplete";
1872
1692
  if (eventResults.length === 0) {
1873
- // §5.3.2: an events-empty body is exactly a §5.2 stamp trace, and is
1874
- // partial, never verified. It says nothing about any event.
1693
+ // Section 5.3.2: an events-empty body is a section 5.2 stamp trace: incomplete, never
1694
+ // verified.
1875
1695
  errors.push(`${STAMP_EVIDENCE_BODY_TYPE} body carries no events -- it evidences no event, only its stamps`);
1876
1696
  }
1877
- pushRefetchGapNotes(errors, "stamps", {
1878
- unavailable: gatewayUnavailable,
1879
- kind: uncheckedKind,
1880
- id: uncheckedId,
1881
- });
1882
1697
  if (anyUnwitnessed) {
1883
- // §5.3.3 rule 6, inherited whole from §5.2.4: for a stamp the witness IS
1884
- // the independent part, so the events resting on it inherit `partial`.
1885
- errors.push("one or more stamps carries no anchor_ref — sealed by Sanning, witnessed by nobody");
1698
+ // Section 5.3.3 rule 6: events resting on an unwitnessed stamp inherit `incomplete`.
1699
+ errors.push("one or more stamps carries no anchor_ref: sealed by Sanning, witnessed by nobody");
1886
1700
  }
1887
1701
  }
1888
1702
  else {
@@ -1897,44 +1711,16 @@ async function verifyStampEvidence(b, specVersionOk, assertedStatus, errors, opt
1897
1711
  onChainChecked,
1898
1712
  checkpoints: [],
1899
1713
  events: eventResults,
1900
- assertedStatus,
1901
1714
  stamps,
1902
1715
  errors,
1903
1716
  };
1904
1717
  }
1905
1718
  /**
1906
1719
  * The optional on-chain re-fetch over `stamps[]`, shared by §5.2 and §5.3.
1907
- *
1908
- * ── Why the results land on `stamps[]` and `checkpoints` stays empty ──
1909
- *
1910
- * A stamp body carries no checkpoints. Synthesising `CheckpointResult`s to
1911
- * carry these outcomes would make a stamp indistinguishable from a checkpoint
1912
- * in every consumer that reads `checkpoints[]` — the exact conflation §5.2.1
1913
- * and §5.3.1 refused twice, most sharply in "a Sanning attestation
1914
- * indistinguishable on the wire from a producer's". They are different
1915
- * artifacts with different signers and different meanings when absent, and a
1916
- * reader must never have to guess which one it is holding. So the stamp
1917
- * results go on the stamps, and `checkpoints: []` keeps saying the true thing:
1918
- * this body has none.
1919
- *
1920
- * ── Why an unwitnessed stamp gets `onChain: null` and NOT all-unreachable ──
1921
- *
1922
- * The anchor-trace path synthesises an all-`unreachable` result for a
1923
- * checkpoint with no `anchor_ref`. That is not copied here, for two reasons.
1924
- *
1925
- * It would be untrue: nothing was asked of any gateway, so reporting that
1926
- * every gateway failed to answer invents a network event. "Not checked" and
1927
- * "we asked and nobody answered" are different facts, and collapsing them is
1928
- * precisely the defect that had a surface reporting an unreachable gateway
1929
- * when it had contacted none.
1930
- *
1931
- * And it would be load-bearing in the wrong direction. Over there the
1932
- * synthesis exists to drive `gatewayUnavailable`, which is what downgrades that
1933
- * run to `partial`. Here §5.2.4 already downgrades an unwitnessed stamp to
1934
- * `partial` on its own, with a better error. Copying the synthesis would add
1935
- * nothing to the status, which is `partial` either way, and would add an
1936
- * unreachable-gateway note for a network event that never happened, based on
1937
- * nothing but whether the caller happened to pass a gateway list.
1720
+ * Results land on the stamps and `checkpoints` stays empty: a stamp must never
1721
+ * be mistaken for a producer checkpoint. An unwitnessed stamp gets `onChain:
1722
+ * null`, not a synthesized all-unreachable result: no gateway was asked, and
1723
+ * §5.2.4 already holds it at `partial`.
1938
1724
  */
1939
1725
  async function refetchStamps(stamps, witnessed, options, errors) {
1940
1726
  const gateways = options.gateways ?? [];
@@ -1955,8 +1741,7 @@ async function refetchStamps(stamps, witnessed, options, errors) {
1955
1741
  for (let i = 0; i < stamps.length; i++) {
1956
1742
  const st = stamps[i];
1957
1743
  const witnessedHex = witnessed[i];
1958
- // No witness named, or bytes we could not read: nothing to re-fetch, and
1959
- // nothing a gateway could confirm or deny. Left as `onChain: null`.
1744
+ // No witness named, or unreadable bytes: nothing to re-fetch.
1960
1745
  if (st.anchorRef === null || witnessedHex === undefined)
1961
1746
  continue;
1962
1747
  if (st.anchorRef.kind === SIMULATED_WITNESS_KIND)
@@ -1974,22 +1759,13 @@ async function refetchStamps(stamps, witnessed, options, errors) {
1974
1759
  uncheckedId = true;
1975
1760
  if (outcome.error)
1976
1761
  st.errors.push(outcome.error);
1977
- // Fold the on-chain dimension in exactly as a checkpoint does: a MISMATCH
1978
- // (false) fails the stamp — the chain does not hold what the bundle says
1979
- // was witnessed. UNREACHABLE (null) leaves an offline-sound stamp ok and
1980
- // downgrades the run to `partial` via gatewayUnavailable. A confirm (true)
1981
- // changes nothing that was not already true offline.
1982
- //
1983
- // `st.ok` is kept and narrowed rather than recomputed: its offline form
1984
- // includes `errors.length === 0`, and re-evaluating that AFTER pushing an
1985
- // unreachable note would fail a stamp for a gateway being down.
1762
+ // As for a checkpoint: a mismatch fails the stamp, unreachable downgrades
1763
+ // the run to `partial`. Narrowed, not recomputed, so an unreachable note
1764
+ // does not fail the stamp.
1986
1765
  st.ok = st.ok && st.onChainOk !== false;
1987
1766
  }
1988
- // A simulated witness is a sandbox plane's stand-in for a chain: there is no
1989
- // chain to look it up on, so it is never counted as checked on one.
1990
- // It stays not checked like any kind this verifier has no adapter for, and
1991
- // the run stays undetermined; what changes is that a run whose only
1992
- // witnesses are simulated no longer says it checked anything on a chain.
1767
+ // A simulated witness has no chain to look up, so it is never counted as
1768
+ // checked on one; the run stays undetermined.
1993
1769
  if (simulated > 0 && onChainKinds === 0) {
1994
1770
  return { onChainChecked: false, gatewayUnavailable, uncheckedKind, uncheckedId };
1995
1771
  }
@@ -2001,24 +1777,17 @@ async function refetchStamps(stamps, witnessed, options, errors) {
2001
1777
  * on a chain.
2002
1778
  */
2003
1779
  export const SIMULATED_WITNESS_KIND = "simulated";
2004
- // How many `sanning.evidence.export/v2` wrappers deep the source bundle may be
2005
- // nested before this path gives up. verifyEvidenceBundle's canonicalization
2006
- // depth bound already rejects a pathological bundle, but the unwrap must be
2007
- // independently bounded rather than trusting that it ran first.
1780
+ // How many export wrappers deep the source bundle may nest. Bounded here
1781
+ // independently of the canonicalization depth bound.
2008
1782
  const MAX_EXPORT_UNWRAP = 4;
2009
1783
  /**
2010
- * Materialize the disclosed raw bytes of a received bundle (evidence-bundle §5.1).
1784
+ * Materialize the disclosed raw bytes of a received bundle (evidence-bundle.md
1785
+ * §5.1). The bundle is verified with {@link verifyEvidenceBundle}, and an
1786
+ * event's bytes are returned only when that verification bound them to the
1787
+ * committed `content_hash`.
2011
1788
  *
2012
- * Verify-then-return: the bundle is verified with {@link verifyEvidenceBundle}
2013
- * (same options — gateways, out-of-band `content`, `fetchImpl`), and an event's
2014
- * bytes are returned ONLY when that verification bound them to the committed
2015
- * `content_hash`. There is no path through this function that yields bytes
2016
- * whose SHA-256 is not the commitment.
2017
- *
2018
- * Returns `Uint8Array`, never a string: the spec commits to raw bytes, and a
2019
- * UTF-8 decode is lossy for any payload that is not valid UTF-8 — re-encoding
2020
- * such a string would produce different bytes and a different hash. Callers who
2021
- * know their payload is text decode it themselves and own that assumption.
1789
+ * Returns `Uint8Array`, never a string: a UTF-8 decode is lossy and would
1790
+ * change the hash.
2022
1791
  */
2023
1792
  export async function disclosedContent(bundle, options = {}) {
2024
1793
  const verification = await verifyEvidenceBundle(bundle, options);
@@ -2039,11 +1808,9 @@ export async function disclosedContent(bundle, options = {}) {
2039
1808
  `(body_type ${JSON.stringify(verification.bodyType)})`);
2040
1809
  return empty();
2041
1810
  }
2042
- // Fail closed on any drift between the body we are decoding and the results
2043
- // we are trusting. Both must be the same events in the same order, or the
2044
- // "these bytes are the bytes that verified" guarantee is void — which is the
2045
- // only guarantee this function makes. A broken wrapper lands here too:
2046
- // verifyEvidenceBundle returns zero event results, so nothing is emitted.
1811
+ // Fail closed unless the decoded body and the trusted results are the same
1812
+ // events in the same order. A broken wrapper yields zero results, so nothing
1813
+ // is emitted.
2047
1814
  if (traceEvents.length !== verification.events.length) {
2048
1815
  errors.push("the bundle did not verify far enough to materialize content: " +
2049
1816
  `${verification.events.length} verified event result(s) for ${traceEvents.length} body event(s)`);
@@ -2066,17 +1833,15 @@ export async function disclosedContent(bundle, options = {}) {
2066
1833
  const evErrors = [];
2067
1834
  let status;
2068
1835
  if (!res.ok || res.contentOk === false) {
2069
- // The event itself is not trustworthy (bad signature, broken inclusion,
2070
- // unbound checkpoint) or its disclosure contradicts the commitment.
1836
+ // The event is not trustworthy, or its disclosure contradicts the commitment.
2071
1837
  status = "failed";
2072
1838
  evErrors.push(res.contentOk === false
2073
- ? "disclosed content does not bind to the committed content_hash — withheld"
2074
- : "the event's own proof chain did not verify — nothing here is safe to materialize");
1839
+ ? "disclosed content does not bind to the committed content_hash; withheld"
1840
+ : "the event's own proof chain did not verify; nothing here is safe to materialize");
2075
1841
  evErrors.push(...res.errors);
2076
1842
  }
2077
1843
  else if (!selection.offered) {
2078
- // Minimal disclosure: the committed hash is there, the bytes are not.
2079
- // Distinct from empty bytes, and never an error.
1844
+ // Minimal disclosure: distinct from empty bytes, and never an error.
2080
1845
  status = "not_disclosed";
2081
1846
  }
2082
1847
  else if (bytes === undefined) {
@@ -2085,11 +1850,9 @@ export async function disclosedContent(bundle, options = {}) {
2085
1850
  evErrors.push(...selection.errors);
2086
1851
  }
2087
1852
  else if (res.contentOk !== true) {
2088
- // Disclosed and decodable, but there is no commitment to check it against
2089
- // (withheld record, or a record committing no content_hash). Unbound
2090
- // bytes are exactly what a consumer must not silently write out.
1853
+ // Nothing to check the bytes against: unbound bytes must not be written.
2091
1854
  status = "undetermined";
2092
- evErrors.push("disclosed bytes are not bound to any committed content_hash — withheld rather than returned");
1855
+ evErrors.push("disclosed bytes are not bound to any committed content_hash; withheld rather than returned");
2093
1856
  evErrors.push(...res.errors);
2094
1857
  }
2095
1858
  else {
@@ -2099,7 +1862,7 @@ export async function disclosedContent(bundle, options = {}) {
2099
1862
  if (verified) {
2100
1863
  if (content.has(res.eventId)) {
2101
1864
  errors.push(`duplicate event_id ${JSON.stringify(res.eventId)}: the content map keeps the first ` +
2102
- "occurrence — read events[] for every event");
1865
+ "occurrence; read events[] for every event");
2103
1866
  }
2104
1867
  else {
2105
1868
  content.set(res.eventId, bytes);
@@ -2114,21 +1877,12 @@ export async function disclosedContent(bundle, options = {}) {
2114
1877
  }
2115
1878
  return { status: verification.status, verification, events, content, errors };
2116
1879
  }
2117
- // The events a bundle carries, unwrapping any evidence-export wrapper(s) to
2118
- // the inline source bundle — mirroring how verifyEvidenceBundle recurses into
2119
- // `source_bundle`, so the events line up with its results.
2120
- // `undefined` when the bundle carries no events this reader understands, which
2121
- // a caller must distinguish from a body that has events and no links.
2122
- //
2123
- // BOTH event-carrying bodies, not just anchor-trace (proof#73): §5.3's
2124
- // `sanning.stamp.evidence/v2` is the hand-over pack, and it presents the same
2125
- // event shape.
2126
- //
2127
- // Exported for `chain.ts`, which needs the same descent and must not derive it
2128
- // again: two answers to "which events is this bundle actually about" inside one
2129
- // kernel is two implementations of one rule, and the one that drifted would be
2130
- // the one nobody was looking at. Deliberately NOT re-exported from `index.ts` —
2131
- // same posture as `side-content.ts`, internal and shared.
1880
+ // The events a bundle carries, through any export wrappers to the inline
1881
+ // source, as `verifyEvidenceBundle` recurses, so they line up with its results.
1882
+ // Both event-carrying bodies (anchor trace and §5.3 stamp evidence). `undefined`
1883
+ // when the bundle carries no events this reader understands. Exported for
1884
+ // `chain.ts`, so there is one answer to which events a bundle is about; not
1885
+ // re-exported from `index.ts`.
2132
1886
  export function resolveTraceEvents(bundle) {
2133
1887
  let cur = bundle;
2134
1888
  for (let depth = 0; depth < MAX_EXPORT_UNWRAP; depth++) {
@@ -2136,14 +1890,6 @@ export function resolveTraceEvents(bundle) {
2136
1890
  return undefined;
2137
1891
  const b = cur;
2138
1892
  const bodyType = typeof b.body_type === "string" ? b.body_type : null;
2139
- // Both event-carrying bodies, not just anchor-trace (proof#73). §5.3's
2140
- // `sanning.stamp.evidence/v2` presents the SAME event shape (a
2141
- // producer-signed envelope, optional `event_bytes`, an inclusion proof),
2142
- // and it is the HAND-OVER PACK: the only artifact an auditor is ever
2143
- // handed. Dispatching on anchor-trace alone meant `readChains` reported
2144
- // zero chains on every pack, however well-formed the links inside its
2145
- // records were, so "is it in order?" read as NOT CHECKED on the one shape
2146
- // that matters. The writers have been emitting those links the whole time.
2147
1893
  if (bodyType === ANCHOR_TRACE_BODY_TYPE ||
2148
1894
  bodyType === STAMP_EVIDENCE_BODY_TYPE) {
2149
1895
  const body = b.body;
@@ -2160,20 +1906,15 @@ export function resolveTraceEvents(bundle) {
2160
1906
  }
2161
1907
  return undefined;
2162
1908
  }
2163
- // ===========================================================================
2164
- // Attested-evidence-export verification (evidence-export.md §5, steps 3–9).
2165
- // Reached from verifyEvidenceBundle AFTER the wrapper (Ed25519 issuer signature
2166
- // + body_hash) has already verified (steps 1–2). This composes the existing
2167
- // evidence path (recompute the inline source verdict) with the one new
2168
- // primitive (embedded RSA-PSS operator attestations, verifyRsaPssSha256).
2169
- // ===========================================================================
2170
- async function verifyExportBody(b, specVersionOk, assertedStatus, errors, options) {
2171
- // Malformed-as-an-export (§5 exit 2): a required field is missing or a key is
2172
- // unparseable — the wrapper verified, but no verdict can be rendered. The
2173
- // wrapper fields stay true (they DID verify) for any consumer that inspects
2174
- // them; the CLI's malformed branch shows the errors and exits 2.
1909
+ // Attested-evidence-export verification (evidence-export.md §5, steps 3 to 9),
1910
+ // reached after the wrapper verified. Recomputes the inline source verdict and
1911
+ // checks the embedded RSA-PSS operator attestations.
1912
+ async function verifyExportBody(b, specVersionOk, errors, options) {
1913
+ // The wrapper verified but no verdict can be rendered: unreadable as an export.
1914
+ // The wrapper fields stay true, because they did verify.
2175
1915
  const exportMalformed = (error) => ({
2176
- status: "malformed",
1916
+ status: "incomplete",
1917
+ unreadable: true,
2177
1918
  specVersionOk,
2178
1919
  bodyHashOk: true,
2179
1920
  signatureOk: true,
@@ -2181,7 +1922,6 @@ async function verifyExportBody(b, specVersionOk, assertedStatus, errors, option
2181
1922
  onChainChecked: false,
2182
1923
  checkpoints: [],
2183
1924
  events: [],
2184
- assertedStatus,
2185
1925
  errors: [...errors, error],
2186
1926
  });
2187
1927
  const body = b.body;
@@ -2199,14 +1939,13 @@ async function verifyExportBody(b, specVersionOk, assertedStatus, errors, option
2199
1939
  Array.isArray(body.kernel_verdict)) {
2200
1940
  return exportMalformed("export body missing kernel_verdict");
2201
1941
  }
2202
- // --- Step 3: source-bundle linkage ----------------------------------------
1942
+ // Step 3: source-bundle linkage.
2203
1943
  const inline = body.source_bundle;
2204
1944
  if (inline === undefined || inline === null) {
2205
- // source_bundle_ref (out-of-line) is not verifiable on a network-isolated
2206
- // machine unless the referenced bytes are co-delivered — the source-
2207
- // dependent checks are UNDETERMINED (exit 3), NOT a failure (§5 step 3).
1945
+ // An out-of-line source is not verifiable offline unless co-delivered: the
1946
+ // source-dependent checks are not checked, never failed.
2208
1947
  return {
2209
- status: "partial",
1948
+ status: "incomplete",
2210
1949
  specVersionOk,
2211
1950
  bodyHashOk: true,
2212
1951
  signatureOk: true,
@@ -2214,11 +1953,10 @@ async function verifyExportBody(b, specVersionOk, assertedStatus, errors, option
2214
1953
  onChainChecked: false,
2215
1954
  checkpoints: [],
2216
1955
  events: [],
2217
- assertedStatus,
2218
1956
  errors: [
2219
1957
  ...errors,
2220
- "source_bundle is not inline (source_bundle_ref) — bytes unavailable offline; " +
2221
- "source-dependent checks undetermined",
1958
+ "source_bundle is not inline (source_bundle_ref): bytes unavailable offline; " +
1959
+ "source-dependent checks not checked",
2222
1960
  ],
2223
1961
  };
2224
1962
  }
@@ -2233,80 +1971,65 @@ async function verifyExportBody(b, specVersionOk, assertedStatus, errors, option
2233
1971
  catch (e) {
2234
1972
  errors.push(`source_bundle canonicalization failed${thrownDetail(e)}`);
2235
1973
  }
2236
- // --- Step 4: recompute the source verdict ---------------------------------
2237
- // The existing evidence path over the inline source bundle — same primitives
2238
- // (Ed25519 envelopes, payload binding, RFC 9162 inclusion, merkle roots,
2239
- // content_ok, and the optional per-gateway on-chain re-fetch, §4.2 / step 7).
1974
+ // Step 4: recompute the source verdict, including the optional re-fetch.
2240
1975
  const sourceResult = await verifyExportSource(inline, options);
2241
- // Attestation data_hash binding (§5 6c).
1976
+ // For the attestation data_hash binding (§5 step 6c).
2242
1977
  const sourceCheckpoints = await indexSourceCheckpoints(inline);
2243
- // --- Step 6: embedded attestation records ---------------------------------
1978
+ // Step 6: embedded attestation records.
2244
1979
  const attestations = [];
2245
1980
  for (const rec of body.attestations) {
2246
1981
  const r = await verifyAttestation(rec, sourceCheckpoints);
2247
1982
  if (r.malformed !== undefined)
2248
1983
  return exportMalformed(r.malformed);
1984
+ if (typeof r.result.fieldError === "string")
1985
+ errors.push(r.result.fieldError);
2249
1986
  attestations.push(r.result);
2250
1987
  }
2251
- // --- Build the recomputed §4 verdict object -------------------------------
2252
1988
  const verdict = buildVerdictObject(sourceResult, attestations);
2253
1989
  if (typeof b.generated_at === "string")
2254
1990
  verdict.as_of = b.generated_at;
2255
- // --- Step 5: verdict agreement (DETERMINISTIC dimensions only) ------------
2256
- // The cached body.kernel_verdict is recompute-don't-trust (principle 3). But
2257
- // agreement compares ONLY the deterministic, offline-recomputable dimensions:
2258
- // per-event signature/payload-binding/inclusion/content, per-checkpoint
2259
- // merkle roots, and per-attestation signature/operator-address/data_hash
2260
- // binding. The on-chain per-gateway dimension is environment/time-dependent —
2261
- // the issuer observed it online at compose time; an offline verifier
2262
- // legitimately sees `null` — so it is INFORMATIONAL and MUST NOT by itself
2263
- // trigger an exit-1 tamper verdict (the verifier's own on-chain outcomes fold
2264
- // in at step 7 only if it re-fetches). Advisory rollups (status / counts /
2265
- // summary / as_of / custody_chain) are derived, not compared. See the §5
2266
- // step-5 erratum flagged in the slice report.
1991
+ // Step 5: the cached `kernel_verdict` is never trusted, and agreement compares
1992
+ // only the deterministic, offline-recomputable dimensions. The on-chain
1993
+ // dimension depends on when and where it was observed, so it must not by
1994
+ // itself read as tamper. Derived rollups are not compared.
2267
1995
  let verdictAgreementOk = false;
2268
1996
  try {
2269
1997
  const cached = projectVerdictForAgreement(body.kernel_verdict);
2270
1998
  const fresh = projectVerdictForAgreement(verdict);
2271
- toleratePreA20Content(cached, fresh, sourceEventsGoverned(inline));
1999
+ tolerateUnboundMessageContent(cached, fresh, sourceEventsGoverned(inline));
2272
2000
  const cachedProjection = jcs(cached);
2273
2001
  const freshProjection = jcs(fresh);
2274
2002
  verdictAgreementOk = cachedProjection === freshProjection;
2275
2003
  if (!verdictAgreementOk) {
2276
2004
  errors.push("recomputed verdict disagrees with the cached kernel_verdict " +
2277
- "(deterministic dimensions) — recompute-don't-trust");
2005
+ "(deterministic dimensions); recompute-don't-trust");
2278
2006
  }
2279
2007
  }
2280
2008
  catch (e) {
2281
2009
  errors.push(`verdict agreement comparison failed${thrownDetail(e)}`);
2282
2010
  }
2283
- // --- Step 7 already folded in by the source recompute ---------------------
2284
- // (verifyEvidenceBundle merged per-gateway outcomes into sourceResult.status:
2285
- // a checkpoint `mismatch` made it "failed"; an all-`unreachable` checkpoint
2286
- // made it "partial" + an unreachable note → exit 3.)
2287
- // Propagate the source recompute's notes so the auditor — and the CLI's
2288
- // exit-3 detection (which keys off "unreachable") — see them.
2011
+ // Step 7 is already folded into `sourceResult.status` by the source recompute.
2012
+ // The CLI's exit-3 detection keys off "unreachable" in these notes.
2289
2013
  for (const e of sourceResult.errors)
2290
2014
  errors.push(`source: ${e}`);
2291
- // --- Step 9: export rollup + exit -----------------------------------------
2015
+ // Step 9: export rollup.
2292
2016
  const anyAttestationFailed = attestations.some((a) => !a.ok);
2293
2017
  let status;
2294
2018
  if (!sourceLinkageOk ||
2295
2019
  !verdictAgreementOk ||
2296
2020
  sourceResult.status === "failed" ||
2297
- sourceResult.status === "malformed" ||
2021
+ sourceResult.unreadable === true ||
2298
2022
  anyAttestationFailed) {
2299
2023
  status = "failed";
2300
2024
  }
2301
- else if (sourceResult.status === "partial") {
2302
- // Undetermined source (a withheld record, or an unreachable gateway):
2303
- // exit 3 either way since 0.13.0. Never a failure.
2304
- status = "partial";
2025
+ else if (sourceResult.status === "incomplete") {
2026
+ // A source not fully checked (a withheld record). Never a failure.
2027
+ status = "incomplete";
2305
2028
  }
2306
2029
  else {
2307
2030
  status = "verified";
2308
2031
  }
2309
- // The DISPLAYED verdict reflects the full recompute (incl. attestations).
2032
+ // The displayed verdict reflects the full recompute, attestations included.
2310
2033
  verdict.status = status;
2311
2034
  verdict.summary = summarizeExport(status, sourceResult, attestations);
2312
2035
  return {
@@ -2316,11 +2039,9 @@ async function verifyExportBody(b, specVersionOk, assertedStatus, errors, option
2316
2039
  signatureOk: true,
2317
2040
  bodyType: EXPORT_BODY_TYPE,
2318
2041
  onChainChecked: sourceResult.onChainChecked,
2319
- // The top-level per-checkpoint / per-event results ARE the recomputed source
2320
- // results, so the CLI's existing rendering shows them unchanged.
2042
+ // The recomputed source results, so the CLI renders them unchanged.
2321
2043
  checkpoints: sourceResult.checkpoints,
2322
2044
  events: sourceResult.events,
2323
- assertedStatus,
2324
2045
  export: {
2325
2046
  sourceLinkageOk,
2326
2047
  sourceStatus: sourceResult.status,
@@ -2328,38 +2049,39 @@ async function verifyExportBody(b, specVersionOk, assertedStatus, errors, option
2328
2049
  attestations,
2329
2050
  verdict,
2330
2051
  status,
2052
+ // A source whose own wrapper time breaks its format is a source that does not follow
2053
+ // its format, as one that cannot be read is.
2054
+ sourceUnreadable: (sourceResult.unreadable === true || sourceResult.generatedAtMalformed === true) && sourceLinkageOk,
2331
2055
  },
2332
2056
  errors,
2333
2057
  };
2334
2058
  }
2335
2059
  export async function recomputeExportVerdict(sourceBundle, attestationRecords, generatedAt, options = {}) {
2336
2060
  const sourceResult = await verifyExportSource(sourceBundle, options);
2337
- // Attestation data_hash binding (§5 6c) — the SAME index construction as
2338
- // verifyExportBody, deliberately shared rather than restated.
2061
+ // The same index `verifyExportBody` builds (§5 step 6c).
2339
2062
  const sourceCheckpoints = await indexSourceCheckpoints(sourceBundle);
2340
2063
  const attestations = [];
2341
2064
  for (const rec of attestationRecords) {
2342
2065
  const r = await verifyAttestation(rec, sourceCheckpoints);
2343
- if (r.malformed !== undefined) {
2344
- throw new Error(`attestation record is malformed: ${r.malformed}`);
2066
+ const malformedField = r.malformed ?? r.result.fieldError ?? undefined;
2067
+ if (malformedField !== undefined) {
2068
+ throw new Error(`attestation record is malformed: ${malformedField}`);
2345
2069
  }
2346
2070
  attestations.push(r.result);
2347
2071
  }
2348
2072
  const verdict = buildVerdictObject(sourceResult, attestations);
2349
2073
  verdict.as_of = generatedAt;
2350
- // Export rollup (§5 step 9). Source linkage and verdict agreement are always
2351
- // satisfied at compose time (the composer computes the source hash itself and
2352
- // there is no prior cached verdict to disagree with), so the only failure
2353
- // inputs here are the recomputed source status and any failed attestation.
2074
+ // Linkage and agreement always hold at compose time, so only the source
2075
+ // status and the attestations can fail the rollup.
2354
2076
  const anyAttestationFailed = attestations.some((a) => !a.ok);
2355
2077
  let status;
2356
2078
  if (sourceResult.status === "failed" ||
2357
- sourceResult.status === "malformed" ||
2079
+ sourceResult.unreadable === true ||
2358
2080
  anyAttestationFailed) {
2359
2081
  status = "failed";
2360
2082
  }
2361
- else if (sourceResult.status === "partial") {
2362
- status = "partial";
2083
+ else if (sourceResult.status === "incomplete") {
2084
+ status = "incomplete";
2363
2085
  }
2364
2086
  else {
2365
2087
  status = "verified";
@@ -2368,19 +2090,10 @@ export async function recomputeExportVerdict(sourceBundle, attestationRecords, g
2368
2090
  verdict.summary = summarizeExport(status, sourceResult, attestations);
2369
2091
  return { verdict, sourceStatus: sourceResult.status, status, attestations };
2370
2092
  }
2371
- // Verify one embedded attestation record (§5 step 6a–d). Returns `malformed`
2372
- // (→ exit 2) for an unparseable RSA key / signature hex or a missing required
2373
- // field; otherwise a per-dimension AttestationResult whose `ok` gates on the
2374
- // three binding checks (sig / operator-address / data_hash). subject_ref is
2375
- // undetermined-tolerant and never gates.
2376
- // The attestation payload fields evidence-export.md §3.1 marks Required, with
2377
- // the type it gives each. A record whose payload lacks one, or carries another
2378
- // type (null included), is malformed: §5 step 9 and §4.5 make "a missing
2379
- // required field" an export the verifier cannot render (exit 2). §5 step 6
2380
- // gates a WELL-FORMED record on signature, operator and data_hash only; these
2381
- // checks decide whether the record is well-formed at all. `level` is the §3.1
2382
- // enum 1 | 2 | 3. The integers are non-negative and within 2**53 - 1 (the
2383
- // §3.1 clarification).
2093
+ // The payload fields evidence-export.md section 3.1 requires, with their types. Read only
2094
+ // under a signature that verifies: a record then missing one, or carrying another type
2095
+ // (null included), is one the issuer signed that does not follow its format (section 4.5,
2096
+ // `follows_rules` disproven). `level` is 1, 2 or 3; integers are non-negative safe integers.
2384
2097
  const PAYLOAD_REQUIRED = [
2385
2098
  ["attested_at", "string"],
2386
2099
  ["tx_id", "string"],
@@ -2408,7 +2121,6 @@ function payloadFieldError(payload) {
2408
2121
  want = "a boolean";
2409
2122
  }
2410
2123
  else if (kind === "integer") {
2411
- // §3.1 (clarified): a non-negative integer no larger than 2**53 - 1.
2412
2124
  ok = typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
2413
2125
  want = "a non-negative integer";
2414
2126
  }
@@ -2419,12 +2131,16 @@ function payloadFieldError(payload) {
2419
2131
  if (!ok)
2420
2132
  return `attestation payload field ${JSON.stringify(name)} is missing or not ${want}`;
2421
2133
  if (name === "attested_at" && !isRfc3339UtcInstant(v)) {
2422
- // evidence-export §3.1 (v3.4): the form `signed_at` pins.
2134
+ // The form `signed_at` pins (evidence-export.md §3.1).
2423
2135
  return 'attestation payload field "attested_at" is not an RFC 3339 UTC date-time with Z naming a real date and time';
2424
2136
  }
2425
2137
  }
2426
2138
  return null;
2427
2139
  }
2140
+ // Verify one embedded attestation record (section 5 step 6). Returns `malformed` (exit 2)
2141
+ // where the signature cannot be checked (no payload, an unparseable key or signature);
2142
+ // otherwise `ok` gates on the signature, then the payload fields under it (`fieldError`),
2143
+ // then the operator address and data_hash. `subject_ref` never gates.
2428
2144
  async function verifyAttestation(rec, sourceCheckpoints) {
2429
2145
  const errs = [];
2430
2146
  const result = {
@@ -2464,16 +2180,12 @@ async function verifyAttestation(rec, sourceCheckpoints) {
2464
2180
  if (typeof rec.signature !== "string") {
2465
2181
  return { result, malformed: "attestation record missing signature" };
2466
2182
  }
2467
- const fieldError = payloadFieldError(payload);
2468
- if (fieldError !== null)
2469
- return { result, malformed: fieldError };
2470
2183
  const rsaKey = { kty: "RSA", n: pub.n, e: pub.e };
2471
2184
  result.operator = typeof payload.operator === "string" ? payload.operator : "";
2472
2185
  result.gateway = typeof payload.gateway === "string" ? payload.gateway : null;
2473
- result.level = typeof payload.level === "number" ? payload.level : null;
2474
- // (a) RSA-PSS-SHA-256 signature over JCS(payload). A malformed sig hex or
2475
- // unimportable key THROWS (malformed → exit 2); a well-formed key that simply
2476
- // fails to verify returns false (→ exit 1).
2186
+ result.level = safeInteger(payload.level);
2187
+ // (a) RSA-PSS-SHA-256 over JCS(payload). Malformed hex or an unimportable key
2188
+ // is malformed (exit 2); a signature that does not verify is false (exit 1).
2477
2189
  let payloadBytes;
2478
2190
  try {
2479
2191
  payloadBytes = utf8(jcs(payload));
@@ -2487,16 +2199,17 @@ async function verifyAttestation(rec, sourceCheckpoints) {
2487
2199
  errs.push("attestation RSA-PSS signature verification failed");
2488
2200
  }
2489
2201
  catch {
2490
- // A FIXED sentence, not the library's. This kernel throws
2491
- // `verifyRsaPssSha256: malformed signature hex: ...` and the Python kernel
2492
- // raises `malformed signature hex: ...`, so quoting either one split the
2493
- // two kernels on a finding a holder can put in an export body it signs,
2494
- // and an export pack's findings reach a case reading through `reasons`
2495
- // (gate 4 on proof#140, finding 1). Callers of `verifyRsaPssSha256` still
2496
- // get this kernel's own words.
2202
+ // A fixed sentence, not the library's, so both kernels report one finding.
2497
2203
  return { result, malformed: "attestation signature or key is malformed" };
2498
2204
  }
2499
- // (b) operator-address binding: base64url(SHA-256(modulus)) == payload.operator.
2205
+ // The fields are read only under a signature that holds: over bytes the issuer did not
2206
+ // sign, a bad field says nothing the failed signature does not.
2207
+ if (result.signatureOk) {
2208
+ result.fieldError = payloadFieldError(payload);
2209
+ if (result.fieldError !== null)
2210
+ errs.push(result.fieldError);
2211
+ }
2212
+ // (b) base64url(SHA-256(modulus)) == payload.operator.
2500
2213
  try {
2501
2214
  const derived = await deriveOperatorAddress(pub.n);
2502
2215
  result.operatorAddressBound = result.operator !== "" && derived === result.operator;
@@ -2507,10 +2220,8 @@ async function verifyAttestation(rec, sourceCheckpoints) {
2507
2220
  catch (e) {
2508
2221
  return { result, malformed: `attestation operator key${thrownDetail(e)}` };
2509
2222
  }
2510
- // (c) checkpoint resolution + data_hash binding. The checkpoint tx data IS the
2511
- // uploaded JCS(envelope) bytes (the same bytes the on-chain re-fetch compares
2512
- // against, §4.2), so the checkpoint's committed content hash is
2513
- // SHA-256(JCS(checkpoint.envelope)).
2223
+ // (c) data_hash binding. The checkpoint's witnessed data is its uploaded
2224
+ // JCS(envelope), so the committed hash is SHA-256(JCS(checkpoint.envelope)).
2514
2225
  const cp = sourceCheckpoints.get(result.checkpointRef);
2515
2226
  result.checkpointResolved = cp !== undefined;
2516
2227
  if (!cp) {
@@ -2530,19 +2241,18 @@ async function verifyAttestation(rec, sourceCheckpoints) {
2530
2241
  errs.push(`cannot recompute checkpoint content hash${thrownDetail(e)}`);
2531
2242
  }
2532
2243
  }
2533
- // (d) subject_ref (§3.2) — well-formedness only (no side-input subject channel
2534
- // in v1); undetermined (null), never a hard failure.
2244
+ // (d) subject_ref (§3.2): well-formedness only; null, never a failure.
2535
2245
  result.subjectRefOk = evaluateSubjectRef(payload.subject_ref, errs);
2536
2246
  result.ok =
2537
2247
  result.signatureOk &&
2248
+ (result.fieldError ?? null) === null &&
2538
2249
  result.operatorAddressBound &&
2539
2250
  result.dataHashBound &&
2540
2251
  result.checkpointResolved;
2541
2252
  return { result };
2542
2253
  }
2543
- // subject_ref (§3.2): absent → null (unbound). Present + well-formed but no
2544
- // side-input subject supplied → null (undetermined, mirrors disclosed-content).
2545
- // Present + malformed → null + a surfaced note (never gates the attestation).
2254
+ // subject_ref (§3.2): null when absent, when well-formed with no side input to
2255
+ // compare against, and when malformed (with a note). Never gates.
2546
2256
  function evaluateSubjectRef(subjectRef, errs) {
2547
2257
  if (subjectRef === undefined || subjectRef === null)
2548
2258
  return null;
@@ -2557,12 +2267,10 @@ function evaluateSubjectRef(subjectRef, errs) {
2557
2267
  errs.push("subject_ref is present but not well-formed ({hash: sha256-hex, type: token})");
2558
2268
  return null;
2559
2269
  }
2560
- return null; // well-formed, no side input → undetermined
2270
+ return null;
2561
2271
  }
2562
- // Build the §4 verdict object (snake_case, spec-canonical) from the recomputed
2563
- // source result + the attestation results. This is the recomputed copy the
2564
- // renderer/verify-API display; it is compared (over its deterministic subset)
2565
- // to the cached body.kernel_verdict in step 5.
2272
+ // Build the §4 verdict object from the recomputed source result and the
2273
+ // attestations: what surfaces display, compared with the cached copy in step 5.
2566
2274
  function buildVerdictObject(source, attestations) {
2567
2275
  const attByCheckpoint = new Map();
2568
2276
  for (const a of attestations) {
@@ -2611,7 +2319,7 @@ function buildVerdictObject(source, attestations) {
2611
2319
  }
2612
2320
  return {
2613
2321
  schema_version: "sanning.evidence.verdict/v1",
2614
- status: source.status === "malformed" ? "failed" : source.status,
2322
+ status: source.unreadable === true ? "failed" : source.status,
2615
2323
  counts: { verified, failed, undetermined },
2616
2324
  events,
2617
2325
  checkpoints,
@@ -2622,8 +2330,7 @@ function toVerdictOnChain(o) {
2622
2330
  return {
2623
2331
  rollup: o.rollup,
2624
2332
  on_chain_ok: o.onChainOk,
2625
- // The kernel emits {gateway, outcome}; block_height is issuer-side
2626
- // enrichment (optional in the §4.4 schema) the offline verifier does not
2333
+ // `block_height` is issuer-side enrichment the offline verifier does not
2627
2334
  // recompute.
2628
2335
  per_gateway: o.perGateway.map((g) => ({ gateway: g.gateway, outcome: g.outcome })),
2629
2336
  };
@@ -2632,15 +2339,12 @@ function eventVerdictStatus(e) {
2632
2339
  if (!e.ok)
2633
2340
  return "failed";
2634
2341
  if (e.payloadBindingOk === null)
2635
- return "partial"; // withheld record — undetermined
2342
+ return "incomplete"; // withheld record
2636
2343
  return "verified";
2637
2344
  }
2638
- // The §5 step-5 agreement projection: strip every environment/time-dependent
2639
- // and derived dimension, leaving only what an offline verifier deterministically
2640
- // recomputes. Tolerant of a hostile/garbled cached verdict (optional chaining +
2641
- // nullish normalization) — a structural difference surfaces as disagreement,
2642
- // which is the correct tamper signal. Works on both the cached snake_case JSON
2643
- // and the freshly built VerdictObject.
2345
+ // The §5 step 5 projection: only what an offline verifier deterministically
2346
+ // recomputes. Tolerates a garbled cached verdict: a structural difference
2347
+ // reads as disagreement, which is the right signal.
2644
2348
  function projectVerdictForAgreement(v) {
2645
2349
  const o = (v ?? {});
2646
2350
  const events = Array.isArray(o.events) ? o.events : [];
@@ -2663,7 +2367,7 @@ function projectVerdictForAgreement(v) {
2663
2367
  return {
2664
2368
  checkpoint_ref: cp.checkpoint_ref ?? null,
2665
2369
  merkle_root_ok: cp.merkle_root_ok ?? null,
2666
- // on_chain DELIBERATELY OMITTED — environment/time-dependent (see step 5).
2370
+ // on_chain omitted: environment and time dependent.
2667
2371
  attestations: atts.map((a) => {
2668
2372
  const at = (a ?? {});
2669
2373
  return {
@@ -2678,11 +2382,9 @@ function projectVerdictForAgreement(v) {
2678
2382
  }),
2679
2383
  };
2680
2384
  }
2681
- // Per event of an export's inline `sanning.anchor.trace/v3` source, in order:
2682
- // whether `envelope-spec.md` §4.1 governs its record. Decoded the way the
2683
- // content binding decodes it, so an event whose record is withheld, not hex or
2684
- // not JCS is not governed here either. The Python twin is
2685
- // `evidence._source_events_governed`.
2385
+ // Per event of an export's inline source, in order: whether §4.1 governs its
2386
+ // record, decoded as the content binding decodes it. Python's twin is
2387
+ // `_source_events_governed`.
2686
2388
  function sourceEventsGoverned(source) {
2687
2389
  if (source === null || typeof source !== "object" || Array.isArray(source))
2688
2390
  return [];
@@ -2718,17 +2420,13 @@ function sourceEventsGoverned(source) {
2718
2420
  }
2719
2421
  });
2720
2422
  }
2721
- // The one difference §5 step 5 does not read as a disagreement
2722
- // (evidence-export.md, evidence-bundle.md v1.19, A20): a cached `content_ok` of
2723
- // `null` against a recomputed `true`, on an event whose record §4.1 governs. A
2724
- // kernel before A20 could not bind a hand-off record's disclosed message, so an
2725
- // export it composed claims less than the recompute proves, and reading that
2726
- // as tamper failed an honest export. Only that direction: a cached `true`
2727
- // against a recomputed `false` or `null`, `null` against `false`, and any
2728
- // difference on a record outside §4.1 still disagree. Edits `fresh` in place
2729
- // to match `cached` on those events. The Python twin is
2730
- // `evidence._tolerate_pre_a20_content`.
2731
- function toleratePreA20Content(cached, fresh, governed) {
2423
+ // The one difference §5 step 5 tolerates: a cached `content_ok` of `null`
2424
+ // against a recomputed `true`, on an event whose record §4.1 governs. A composer
2425
+ // that could not bind hand-off messages claims less than the recompute proves,
2426
+ // which is not tamper. Only that direction; every other difference still
2427
+ // disagrees. Edits `fresh` in place. Python's twin is
2428
+ // `_tolerate_unbound_message_content`.
2429
+ function tolerateUnboundMessageContent(cached, fresh, governed) {
2732
2430
  const n = Math.min(cached.events.length, fresh.events.length);
2733
2431
  for (let i = 0; i < n; i++) {
2734
2432
  if (governed[i] === true &&
@@ -2744,69 +2442,35 @@ function summarizeExport(status, source, attestations) {
2744
2442
  `(${source.events.length} event(s) across ${source.checkpoints.length} checkpoint(s)); ` +
2745
2443
  `${attOk}/${attestations.length} operator attestation(s) valid.`);
2746
2444
  }
2747
- // Re-fetch a checkpoint's raw bytes from EACH gateway and classify every
2748
- // gateway's individual outcome (evidence-export.md §4.2), then roll them up
2749
- // worst-finding-wins. Unlike the pre-§4.2 first-responsive-wins collapse, this
2750
- // records a per-gateway array so a confirm/mismatch/unreachable MIX across
2751
- // gateways is preserved; the derived collapsed `onChainOk` reproduces the old
2752
- // single-boolean exactly for a single gateway.
2445
+ // Re-fetch a checkpoint's bytes from each gateway and record each outcome
2446
+ // (evidence-export.md §4.2), then roll them up worst-finding-wins.
2753
2447
  async function refetchCheckpoint(cp, anchorRef, gateways, fetchImpl) {
2754
2448
  let expectedHex;
2755
2449
  try {
2756
- // A checkpoint's witnessed bytes are its envelope RE-CANONICALIZED. The
2757
- // bundle carries the envelope as a JSON object, not as bytes, so what was
2758
- // uploaded has to be recomputed from it. See `refetchStamp` for the case
2759
- // where this step is not merely unnecessary but WRONG.
2450
+ // The bundle carries the envelope as an object, so its witnessed bytes are
2451
+ // recomputed as JCS. Stamps differ: see `refetchStamp`.
2760
2452
  expectedHex = toHex(utf8(jcs(cp.envelope)));
2761
2453
  }
2762
2454
  catch {
2763
- // Cannot canonicalize the checkpoint envelope — every gateway is a mismatch
2764
- // (we can never confirm bytes we cannot compute).
2455
+ // Bytes we cannot compute can never be confirmed: every gateway mismatches.
2765
2456
  expectedHex = undefined;
2766
2457
  }
2767
2458
  return refetchAnchored("checkpoint", expectedHex, anchorRef, gateways, fetchImpl);
2768
2459
  }
2769
- // Re-fetch a STAMP's witnessed bytes (evidence-bundle.md §5.2.2 / §5.3.2).
2770
- //
2771
- // ── The one step this path must NOT copy from the checkpoint path ──
2772
- //
2773
- // `refetchCheckpoint` recomputes `JCS(envelope)` because a checkpoint's
2774
- // witnessed bytes are not in the bundle — only the object they were derived
2775
- // from is. A stamp is the opposite case: §5.2.2 defines `stamp_bytes` as
2776
- // "lowercase hex of the exact JCS bytes that were handed to the witness", and
2777
- // §5.3.2 repeats it — *"the EXACT bytes that were witnessed, verbatim"*. They
2778
- // are already the bytes, already hex, already lowercase (`isLowerHex` gated
2779
- // them), and `stamp_ref` is their SHA-256.
2780
- //
2781
- // So the expected hex IS `stamp_bytes`, and re-canonicalizing would be a
2782
- // defect, not a redundancy: it would compare the chain against bytes NOBODY
2783
- // SIGNED. On a canonical body the two agree and the bug hides; on a body that
2784
- // is not canonical — the exact case §5.2.2 makes the kernel check for, because
2785
- // the plane replays these bytes as text precisely so a normalizing serializer
2786
- // cannot alter them — a re-canonicalizing verifier reports `mismatch` against
2787
- // a chain that faithfully holds what it was given. A false accusation, from
2788
- // the verifier, about untampered evidence.
2789
- //
2790
- // The bytes handed here are the SAME value `verifyStampEntry` hashed to derive
2791
- // `stamp_ref`, threaded through rather than re-read from the body, so the bytes
2792
- // graded and the bytes compared cannot drift apart.
2460
+ // Re-fetch a stamp's witnessed bytes (§5.2.2, §5.3.2). Unlike a checkpoint,
2461
+ // `stamp_bytes` already are the exact witnessed bytes, so they are compared
2462
+ // verbatim: re-canonicalizing would compare the chain against bytes nobody
2463
+ // signed and accuse a faithful chain of mismatch on a non-canonical body. The
2464
+ // hex is the same value `verifyStampEntry` hashed for `stamp_ref`.
2793
2465
  async function refetchStamp(witnessedHex, anchorRef, gateways, fetchImpl) {
2794
2466
  return refetchAnchored("stamp", witnessedHex, anchorRef, gateways, fetchImpl);
2795
2467
  }
2796
- // The part both paths share: probe every gateway, roll the outcomes up under
2797
- // the ONE rule (`rollupOnChain`), and phrase the finding. Extracted rather than
2798
- // copied — a second rollup rule is a second place for "mismatch beats confirm
2799
- // beats unreachable" to drift, and the drifted copy is the one nobody reads.
2800
- // The subject only ever selects wording; it never changes a verdict.
2468
+ // Shared by both paths: probe every gateway, roll up with `rollupOnChain`, and
2469
+ // phrase the finding. The subject selects wording only, never a verdict.
2801
2470
  async function refetchAnchored(subject, expectedHex, anchorRef, gateways, fetchImpl) {
2802
- // Only a `kind` this kernel has a witness adapter for is re-fetched
2803
- // (evidence-bundle.md §5.1: "where the verifier has an adapter for its
2804
- // `kind`"; §5.1.1: an unrecognised kind MUST NOT fail verification). An `id`
2805
- // of any other kind is not an Arweave id, so whatever a gateway serves at
2806
- // that path says nothing about the witness, and comparing it would turn an
2807
- // unrelated 200 into a false mismatch. No fetch, `onChain: null`, and the
2808
- // run goes `partial`: the caller asked for the witness check and it was not
2809
- // made.
2471
+ // Only a `kind` with a witness adapter is re-fetched (§5.1.1: an unrecognised
2472
+ // kind must not fail verification). No fetch, `onChain: null`, and the run is
2473
+ // `partial`.
2810
2474
  if (!WITNESS_ADAPTER_KINDS.has(anchorRef.kind)) {
2811
2475
  return {
2812
2476
  onChain: null,
@@ -2815,11 +2479,9 @@ async function refetchAnchored(subject, expectedHex, anchorRef, gateways, fetchI
2815
2479
  error: `no witness adapter for kind '${anchorRef.kind}'; the witness was not checked`,
2816
2480
  };
2817
2481
  }
2818
- // The id becomes a URL path segment. Anything but a 43-character base64url
2819
- // transaction id can leave the gateway's base path once URL parsing
2820
- // normalises it (`../x`, `%2e%2e/x`), or drop part of the path (`a#b`,
2821
- // `a?b`), and the two kernels would then request different URLs. Such an id
2822
- // is never fetched and never failed: the witness is not checked.
2482
+ // The id becomes a URL path segment: anything but a 43-character base64url id
2483
+ // could escape the gateway's base path or make the kernels request different
2484
+ // URLs. Never fetched, never failed: the witness is not checked.
2823
2485
  if (!ARWEAVE_ID_RE.test(anchorRef.id)) {
2824
2486
  return {
2825
2487
  onChain: null,
@@ -2848,16 +2510,13 @@ async function refetchAnchored(subject, expectedHex, anchorRef, gateways, fetchI
2848
2510
  }
2849
2511
  return { onChain, unavailable: onChain.rollup === "unreachable", unchecked: null, error };
2850
2512
  }
2851
- // The `anchor_ref.kind` values this kernel can re-fetch from a gateway
2852
- // (evidence-bundle.md §5.1.1 registers exactly `arweave`). Any other kind is
2853
- // left unchecked by `refetchAnchored`, never probed.
2513
+ // The `anchor_ref.kind` values with a witness adapter (§5.1.1 registers `arweave`).
2854
2514
  const WITNESS_ADAPTER_KINDS = new Set(["arweave"]);
2855
2515
  // The only `arweave` ids the adapter looks up: a 43-character base64url
2856
2516
  // transaction or data-item id.
2857
2517
  const ARWEAVE_ID_RE = /^[A-Za-z0-9_-]{43}$/;
2858
- // The bundle-level notes for a run where requested re-fetches were not made.
2859
- // Each note keeps the phrase "could not be re-fetched", which is what both
2860
- // CLIs map to exit 3. The order is fixed so the two kernels emit the same list.
2518
+ // Notes for requested re-fetches that were not made. Each keeps "could not be
2519
+ // re-fetched", which both CLIs map to exit 3. Fixed order across kernels.
2861
2520
  function pushRefetchGapNotes(errors, subject, gaps) {
2862
2521
  if (gaps.unavailable) {
2863
2522
  errors.push(`one or more ${subject} could not be re-fetched from the supplied gateways`);
@@ -2869,8 +2528,7 @@ function pushRefetchGapNotes(errors, subject, gaps) {
2869
2528
  errors.push(`one or more ${subject} name an Arweave witness id this verifier cannot look up, so it could not be re-fetched`);
2870
2529
  }
2871
2530
  }
2872
- // One gateway's outcome for one checkpoint tx: confirm (bytes match), mismatch
2873
- // (bytes disagree), or unreachable (no response / !ok / network error).
2531
+ // One gateway's outcome: confirm, mismatch, or unreachable.
2874
2532
  async function probeGateway(anchorRef, gateway, expectedHex, fetchImpl) {
2875
2533
  const url = `${gateway.replace(/\/+$/, "")}/${anchorRef.id}`;
2876
2534
  let resp;
@@ -2891,10 +2549,9 @@ async function probeGateway(anchorRef, gateway, expectedHex, fetchImpl) {
2891
2549
  }
2892
2550
  if (expectedHex === undefined)
2893
2551
  return "mismatch";
2894
- // The on-chain bytes are the uploaded envelope bytes (JCS-canonical, per the
2895
- // family upload invariant). Compare structurally too, so a gateway that
2896
- // re-pretty-prints does not spuriously fail — but the canonical re-hash is
2897
- // the load-bearing check.
2552
+ // The on-chain bytes are the uploaded JCS bytes. The structural compare
2553
+ // tolerates a gateway that re-pretty-prints; the canonical match is the
2554
+ // load-bearing check.
2898
2555
  if (toHex(onChainBytes) === expectedHex)
2899
2556
  return "confirm";
2900
2557
  try {
@@ -2903,14 +2560,12 @@ async function probeGateway(anchorRef, gateway, expectedHex, fetchImpl) {
2903
2560
  return "confirm";
2904
2561
  }
2905
2562
  catch {
2906
- // not JSON / not parseable — a genuine mismatch
2563
+ // Not parseable: a genuine mismatch.
2907
2564
  }
2908
2565
  return "mismatch";
2909
2566
  }
2910
- // Rollup rule (§4.2): mismatch if ANY gateway is mismatch; else confirm if ANY
2911
- // is confirm; else unreachable. The collapsed `onChainOk`: false on mismatch,
2912
- // true on confirm, null on all-unreachable. An empty gateway list is treated as
2913
- // all-unreachable (no signal).
2567
+ // Rollup (§4.2): mismatch if any gateway mismatched, else confirm if any
2568
+ // confirmed, else unreachable. An empty list is all-unreachable.
2914
2569
  function rollupOnChain(perGateway) {
2915
2570
  let rollup = "unreachable";
2916
2571
  if (perGateway.some((g) => g.outcome === "mismatch"))
@@ -2926,14 +2581,7 @@ function getEventId(env) {
2926
2581
  }
2927
2582
  return "<unknown>";
2928
2583
  }
2929
- // The side-input presence rule lives in `side-content.ts`, stated ONCE.
2930
- //
2931
- // It used to live here as a private `lookupSideContent`, and `grading.ts`
2932
- // carried a second, laxer answer to the same question (`hasEntry`: own +
2933
- // enumerable, nothing more). On one and the same content map the grader said
2934
- // PRESENT while this verifier said nothing was offered, and `gradePack` then
2935
- // returned `incomplete` with ZERO causes. Read `side-content.ts` for the rule
2936
- // and for what each clause of it cost.
2584
+ // The side-input presence rule lives in `./side-content.ts`, stated once.
2937
2585
  function selectDisclosedBytes(ev, eventId, sideContent) {
2938
2586
  const errors = [];
2939
2587
  let inBody;
@@ -2958,15 +2606,8 @@ function selectDisclosedBytes(ev, eventId, sideContent) {
2958
2606
  errors.push(`supplied content for ${eventId} is not hex${thrownDetail(e)}`);
2959
2607
  }
2960
2608
  }
2961
- // `offered` comes from the SAME test that produced the value, never from
2962
- // `value !== undefined` — that is the conflation this whole guard exists to
2963
- // remove, and it was removed from ONE of the two operands. The left one
2964
- // carried it unchanged: an in-body `"content": null` is `!== undefined`, so
2965
- // the event was marked offered while no bytes were selected, and the kernel
2966
- // split against itself — `verifyEvidenceBundle` VERIFIED, `disclosedContent`
2967
- // FAILED, on one bundle. `null` is how an optional field's absence
2968
- // serializes; `inBodyContentOffered` states that once, beside the side-input
2969
- // rule it mirrors.
2609
+ // `offered` comes from the same tests that selected the value, never from
2610
+ // `value !== undefined`: an in-body `null` is an absent optional field.
2970
2611
  return {
2971
2612
  inBody,
2972
2613
  side,