@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/src/evidence.ts CHANGED
@@ -1,29 +1,18 @@
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
-
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
+
10
+ import { readAgentRecords, type AgentHeldRecord, type AgentRecordReading } from "./agent-record.js";
11
+ import { isObject } from "./json-schema.js";
19
12
  import {
20
13
  deriveOperatorAddress,
21
14
  ed25519Verify,
22
15
  hexToBytes,
23
- // The one hex-field test, which lives in `crypto.ts` beside the one hex
24
- // reader (proof#146). It was a second copy here until then, with the same
25
- // body: two copies of one rule is how two kernels drift apart one edit at a
26
- // time, which is the whole defect class this fix is about.
27
16
  isHexField,
28
17
  sha256Hex,
29
18
  utf8,
@@ -33,13 +22,13 @@ import { safeInteger, signedLeafCount } from "./grading.js";
33
22
  import { isConformantHandoffRecord, isHandoffNamespace, type HeldRecord } from "./handoff.js";
34
23
  import { leafHash, verifyInclusion } from "./merkle.js";
35
24
  import {
36
- DuplicateKeyError,
37
25
  MAX_CANONICAL_DEPTH,
38
26
  contentHashes,
39
27
  exceedsDepth,
40
28
  isRfc3339UtcInstant,
41
29
  jcs,
42
30
  jcsParse,
31
+ specVersionSupported,
43
32
  verifyEnvelope,
44
33
  } from "./verifier.js";
45
34
  import {
@@ -47,42 +36,28 @@ import {
47
36
  sideContentEntry,
48
37
  type SideEntry,
49
38
  } from "./side-content.js";
50
- import type { Envelope } from "./types.js";
39
+ import type { Envelope, EnvelopeFault } from "./types.js";
40
+ import { ENVELOPE_FAULTS } from "./types.js";
51
41
 
52
- // Only the evidence-bundle major(s) this kernel verifies. A new major is a
53
- // deliberate one-entry addition HERE — same fail-closed discipline as the
54
- // envelope verifier's ACCEPTED_SPEC_MAJORS.
55
- /** The accepted `sanning.evidence/v1` wrapper majors; exported in both kernels (P-01, 0.13.0). */
42
+ // Fail-closed, like ACCEPTED_SPEC_MAJORS: a new major is a one-entry addition here.
43
+ /** The accepted evidence-bundle wrapper majors. */
56
44
  export const ACCEPTED_EVIDENCE_MAJORS: readonly string[] = Object.freeze(["sanning.evidence/v1"]);
57
45
 
58
- // The body_type this module knows how to verify structurally. An unknown
59
- // body_type is not a hard failure of the WRAPPER (the signature + body_hash
60
- // still verify), but its body cannot be re-derived, so the per-body verdict is
61
- // "undetermined" rather than "verified".
62
- //
63
- // The producer-to-agent cut (W-3, DL-73) renamed `record_bytes` to
64
- // `event_bytes` and moved every body that carries it to a new major. This
65
- // kernel reads ONLY those majors: `sanning.anchor.trace/v1` and `/v2`,
66
- // `sanning.stamp.evidence/v1` and `sanning.evidence.export/v1` are unknown body
67
- // types here and answer `partial`. DL-73 releases evidence made before the cut
68
- // from any obligation to keep verifying, so there is no second field name to
69
- // read and no dual-accept path.
46
+ // The anchor-trace body this module verifies structurally. An unknown
47
+ // body_type does not fail the wrapper, but its body cannot be re-derived, so
48
+ // it reads `incomplete`. Bodies before the `event_bytes` majors are unknown here.
70
49
  export const ANCHOR_TRACE_BODY_TYPE = "sanning.anchor.trace/v3";
71
50
 
72
51
  /**
73
- * The name `event_bytes` replaced. Under the new majors a body entry that still
74
- * carries it is a hard reject, never an ignored unknown field: ignored, it
75
- * would read as a withheld record and the event would verify with nothing
76
- * checked, the fail-open the new majors exist to prevent (governance.md §8,
77
- * W-3).
52
+ * The field name `event_bytes` replaced. A body entry still carrying it is a
53
+ * hard reject: ignored, it would read as a withheld record and verify with
54
+ * nothing checked.
78
55
  */
79
56
  export const LEGACY_EVENT_BYTES_FIELD = "record_bytes";
80
57
 
81
58
  /**
82
- * Name an envelope the accept-set refused. Without this a bundle whose
83
- * envelopes carry a retired profile major (a half-migrated producer still
84
- * writing `sanning.events/v1`) fails with no reason at all. Spelled with
85
- * `JSON.stringify` so both kernels write the same sentence.
59
+ * Name an envelope the accept-set refused, so a retired profile major fails
60
+ * with a reason. The same sentence in both kernels.
86
61
  */
87
62
  function specVersionFinding(
88
63
  what: "checkpoint" | "event",
@@ -97,10 +72,7 @@ function specVersionFinding(
97
72
  return `${what} envelope spec_version ${JSON.stringify(sv)} is not accepted by this kernel`;
98
73
  }
99
74
 
100
- /**
101
- * The reject for an entry that still carries `record_bytes`. One sentence,
102
- * spelled the same in both kernels, so a negative vector can assert it.
103
- */
75
+ /** The reject for an entry that still carries `record_bytes`. The same sentence in both kernels. */
104
76
  function legacyEventBytesError(
105
77
  entry: object,
106
78
  what: "checkpoint" | "event",
@@ -113,43 +85,25 @@ function legacyEventBytesError(
113
85
  );
114
86
  }
115
87
 
116
- // The issuer-composed attested-evidence-export body (specs/evidence-export.md
117
- // §2). The wrapper is unchanged (`sanning.evidence/v1`, Ed25519-signed); this body
118
- // carries a cached verdict + the inline source bundle + embedded RSA-PSS
119
- // operator attestations. verifyEvidenceBundle dispatches on it to
120
- // verifyExportBody (§5).
88
+ // The attested-evidence-export body (specs/evidence-export.md §2): a cached
89
+ // verdict, the inline source bundle and RSA-PSS operator attestations.
121
90
  export const EXPORT_BODY_TYPE = "sanning.evidence.export/v2";
122
91
 
123
92
  /**
124
- * The Sanning-signed interval stamp body (evidence-bundle.md §5.2, DL-58).
125
- *
126
- * Its own `body_type` rather than a widened anchor-trace body, and the reason
127
- * is the whole decision: an anchor-trace checkpoint carries a PRODUCER-signed
128
- * `sanning.events/v2` envelope, and a stamp has no producer signature at all:
129
- * Sanning places it, under DL-52, for a customer who holds no chain identity.
130
- * The two rejected options were making `envelope` optional and synthesising an
131
- * envelope that Sanning signs; the second would have made a Sanning
132
- * attestation indistinguishable ON THE WIRE from a producer's, which is
133
- * unrecoverable once witnessed.
93
+ * The Sanning-signed interval stamp body (evidence-bundle.md §5.2). Its own
94
+ * body type, because a stamp has no producer signature: a Sanning attestation
95
+ * must never be indistinguishable on the wire from a producer's.
134
96
  */
135
97
  export const STAMP_TRACE_BODY_TYPE = "sanning.stamp.trace/v1";
136
98
  export const STAMP_EVIDENCE_BODY_TYPE = "sanning.stamp.evidence/v2";
137
99
 
138
100
  /**
139
- * The version string INSIDE a witnessed stamp body — a different string from
140
- * the `body_type` above, and the two are easy to confuse.
141
- *
142
- * `sanning.stamp.trace/v1` discriminates the BUNDLE BODY: it is the reader's
143
- * dispatch key. `sanning.stamp/v1` is the control plane's own version of the
144
- * bytes it hands a witness (`control-plane/src/stamp-tree.ts`,
145
- * `STAMP_BODY_VERSION`). A `sanning.stamp.trace/v1` body pins the inner
146
- * version to exactly this value: two answers to one question inside one
147
- * signature is the §5.1.2 mixed-vocabulary reject, one layer down.
101
+ * The version string inside a witnessed stamp body, distinct from the
102
+ * `body_type` above (the dispatch key). A stamp trace body pins it to exactly
103
+ * this value (§5.1.2).
148
104
  */
149
105
  export const STAMP_BODY_VERSION = "sanning.stamp/v1";
150
106
 
151
- // ---- Wire types (evidence-bundle.md §2 + §5.1) ------------------------------
152
-
153
107
  export interface EvidenceIssuer {
154
108
  kind: string;
155
109
  tenant_id?: string;
@@ -157,24 +111,9 @@ export interface EvidenceIssuer {
157
111
  [k: string]: unknown;
158
112
  }
159
113
 
160
- export interface EvidenceVerdict {
161
- status: string;
162
- summary?: string;
163
- counts?: Record<string, number>;
164
- as_of?: string;
165
- [k: string]: unknown;
166
- }
167
-
168
114
  /**
169
- * Where a commitment was witnessed (evidence-bundle.md §5.1.1).
170
- *
171
- * The same three-part shape the control plane indexes (`kind` / one promoted
172
- * `id` / a typed remainder), so the signed body, the index row and the SDK
173
- * artifact stop being three vocabularies for one fact.
174
- *
175
- * NOT ONE FIELD HERE NAMES A CHAIN. Arweave's chunk proof, data_path, offsets
176
- * and packing live in `ref`, opaque to every reader above this seam. A field
177
- * that would need renaming for a second witness belongs in `ref` by definition.
115
+ * Where a commitment was witnessed (evidence-bundle.md §5.1.1). No field here
116
+ * names a chain: chain-specific detail lives in the opaque `ref`.
178
117
  */
179
118
  export interface AnchorRef {
180
119
  kind: string;
@@ -182,18 +121,16 @@ export interface AnchorRef {
182
121
  ref?: Record<string, unknown>;
183
122
  }
184
123
 
185
- // `sanning.anchor.trace/v3` body (§5.1). Uint8Array fields ride as lowercase hex.
124
+ // `sanning.anchor.trace/v3` body (§5.1). Byte fields ride as lowercase hex.
186
125
  export interface TraceCheckpoint {
187
126
  /**
188
- * The checkpoint's identity and the join key, DERIVED: hex SHA-256 of the
189
- * decoded `event_bytes`. Recomputed by the verifier, never taken from the
190
- * label.
127
+ * Hex SHA-256 of the decoded `event_bytes`: the checkpoint's identity and join
128
+ * key. Recomputed by the verifier, never trusted.
191
129
  */
192
130
  checkpoint_ref: string;
193
131
  /**
194
- * Where it was witnessed, IF IT HAS BEEN. Optional, and absent is honest:
195
- * under DL-52 a checkpoint is routinely placed before it is witnessed, and a
196
- * required field that must be filled dishonestly is a defect in the format.
132
+ * Where it was witnessed, if it has been. Absent is honest: checkpoints are
133
+ * placed before they are witnessed.
197
134
  */
198
135
  anchor_ref?: AnchorRef;
199
136
  envelope: Envelope;
@@ -213,9 +150,8 @@ export interface TraceInclusion {
213
150
  export interface TraceEvent {
214
151
  envelope: Envelope;
215
152
  event_bytes?: string; // hex of JCS(event record); omitted == record withheld
216
- // Opt-in disclosed raw bytes (lowercase hex) whose SHA-256 MUST equal the
217
- // committed event.content_hash inside event_bytes (evidence-bundle §5.1).
218
- // Default-absent (minimal disclosure); absent ⇒ undetermined, not failed.
153
+ // Opt-in disclosed raw bytes (lowercase hex) whose SHA-256 must equal the
154
+ // committed `event.content_hash` (§5.1). Absent is undetermined, not failed.
219
155
  content?: string;
220
156
  inclusion: TraceInclusion;
221
157
  }
@@ -225,16 +161,10 @@ export interface AnchorTraceBody {
225
161
  events: TraceEvent[];
226
162
  }
227
163
 
228
- // ---- `sanning.stamp.trace/v1` wire types (evidence-bundle.md §5.2) ---------
229
-
230
164
  /**
231
- * One sub-tree of a stamp, as the WITNESSED bytes carry it.
232
- *
233
- * Keyed in the body by a blinded reference (an HMAC under a per-org secret we
234
- * never publish), so a reader learns that the org runs N agents and their
235
- * relative volumes and learns no name. The RANGE is deliberately in the clear:
236
- * it is what makes a later per-agent slice claim checkable by a third party
237
- * without Sanning, and hiding it would put us back in the trust path.
165
+ * One sub-tree of a stamp, as the witnessed bytes carry it. Keyed by a blinded
166
+ * reference, so a reader learns no agent name. The range is in the clear so a
167
+ * per-agent slice claim is checkable without Sanning.
238
168
  */
239
169
  export interface StampSubtree {
240
170
  first_leaf: number;
@@ -244,15 +174,12 @@ export interface StampSubtree {
244
174
  }
245
175
 
246
176
  /**
247
- * The stamp body as the control plane witnesses it
248
- * (`control-plane/src/stamp-tree.ts`, `StampBody`).
249
- *
250
- * Never read from the bundle directly — it is parsed out of `stamp_bytes`,
251
- * which is the only authority. Present as a type so the parse has a shape.
177
+ * The stamp body as the control plane witnesses it. Only ever parsed out of
178
+ * `stamp_bytes`, the sole authority.
252
179
  */
253
180
  export interface StampBody {
254
181
  stamp_version: string;
255
- /** The org, BLINDED. Not an org id, and not reversible without the secret. */
182
+ /** The org, blinded. Not reversible without the secret. */
256
183
  org_ref: string;
257
184
  interval_start: string;
258
185
  interval_end: string;
@@ -263,18 +190,13 @@ export interface StampBody {
263
190
 
264
191
  /** One entry of a `sanning.stamp.trace/v1` body's `stamps[]` (§5.2). */
265
192
  export interface StampEntry {
266
- /**
267
- * The stamp's identity within this bundle, DERIVED: hex SHA-256 of the
268
- * DECODED `stamp_bytes`, not of the hex text. A verifier MUST recompute it.
269
- */
193
+ /** Hex SHA-256 of the decoded `stamp_bytes`, not the hex text. Recomputed by the verifier. */
270
194
  stamp_ref: string;
271
195
  /** Lowercase hex of the exact JCS bytes that were handed to the witness. */
272
196
  stamp_bytes: string;
273
197
  /**
274
- * Where the stamp was witnessed, IF IT HAS BEEN (§5.1.1). Absent is honest:
275
- * a sealed-but-unconfirmed stamp is witnessed by nobody, and an emitter that
276
- * filled this in from a submission receipt would be claiming a chain
277
- * position that does not exist yet.
198
+ * Where the stamp was witnessed, if it has been (§5.1.1). Absent is honest: a
199
+ * submission receipt is not a chain position.
278
200
  */
279
201
  anchor_ref?: AnchorRef;
280
202
  }
@@ -283,13 +205,9 @@ export interface StampTraceBody {
283
205
  stamps: StampEntry[];
284
206
  }
285
207
 
286
- // ---- `sanning.stamp.evidence/v2` wire types (evidence-bundle.md §5.3) -----
287
- //
288
- // Both halves of a DL-52 record, bound: the producer signs the envelope, and
289
- // Sanning signs and witnesses the stamp whose root commits to it. Neither
290
- // earlier body joins them, and a field added to §5.2 could not — a conformant
291
- // reader MUST ignore fields it does not know (envelope-spec.md §2), so the
292
- // join has to live in a body_type readers DISPATCH on. See §5.3.1.
208
+ // `sanning.stamp.evidence/v2` (evidence-bundle.md §5.3): binds the producer's
209
+ // envelope to the Sanning stamp whose root commits to it. Its own body type,
210
+ // because readers ignore unknown fields and the join must be dispatched on.
293
211
 
294
212
  /** §5.3's inclusion: §5.1's shape with `stamp_ref` as the join key. */
295
213
  export interface StampEvidenceInclusion {
@@ -297,7 +215,7 @@ export interface StampEvidenceInclusion {
297
215
  leaf_index: number;
298
216
  leaf_count: number;
299
217
  audit_path: string[]; // hex[]
300
- /** Resolves against `stamps[].stamp_ref` in the SAME body. */
218
+ /** Resolves against `stamps[].stamp_ref` in the same body. */
301
219
  stamp_ref: string;
302
220
  }
303
221
 
@@ -309,7 +227,7 @@ export interface StampEvidenceEvent {
309
227
  }
310
228
 
311
229
  export interface StampEvidenceBody {
312
- /** Byte-identical to §5.2.2 — one definition, one place to change. */
230
+ /** Byte-identical to §5.2.2. */
313
231
  stamps: StampEntry[];
314
232
  events: StampEvidenceEvent[];
315
233
  }
@@ -320,7 +238,6 @@ export interface EvidenceBundle {
320
238
  issuer: EvidenceIssuer;
321
239
  generated_at: string;
322
240
  gateway?: string | null;
323
- verdict: EvidenceVerdict;
324
241
  body?: unknown;
325
242
  body_ref?: string;
326
243
  body_hash: string;
@@ -331,20 +248,15 @@ export interface EvidenceBundle {
331
248
  [k: string]: unknown;
332
249
  }
333
250
 
334
- // ---- Verification result ----------------------------------------------------
335
-
336
251
  export type EvidenceStatus =
337
252
  | "verified" // every in-scope item passed (sig + integrity + inclusion)
338
- | "partial" // integrity confirmed but full proof incomplete (e.g. records withheld)
339
253
  | "failed" // a check failed: bad wrapper sig / tamper / broken inclusion
340
- | "malformed"; // structurally unparseable / unknown major / body_hash mismatch
341
-
342
- // Per-gateway on-chain outcome for one checkpoint tx (evidence-export.md §4.2).
343
- // The on-chain re-fetch resolves each checkpoint's anchor_ref.id against EACH configured
344
- // gateway and records that gateway's individual outcome:
345
- // confirm — the gateway returned the tx and its bytes match the record.
346
- // mismatch — the gateway returned the tx but its bytes disagree — a finding.
347
- // unreachable — no response / timeout / no such tx — an availability gap.
254
+ | "incomplete"; // nothing failed and something could not be checked or read
255
+
256
+ // Per-gateway on-chain outcome for one checkpoint (evidence-export.md §4.2):
257
+ // confirm the gateway returned the tx and its bytes match.
258
+ // mismatch the bytes disagree: a finding.
259
+ // unreachable no response, timeout or no such tx: an availability gap.
348
260
  export type OnChainOutcome = "confirm" | "mismatch" | "unreachable";
349
261
 
350
262
  export interface PerGatewayOutcome {
@@ -353,12 +265,10 @@ export interface PerGatewayOutcome {
353
265
  }
354
266
 
355
267
  export interface OnChainResult {
356
- // Rollup (worst-finding-wins, then best-evidence): mismatch if ANY gateway is
357
- // mismatch; else confirm if ANY is confirm; else unreachable.
268
+ // Worst finding wins: mismatch if any gateway mismatched, else confirm if
269
+ // any confirmed, else unreachable.
358
270
  rollup: OnChainOutcome;
359
- // Derived collapsed field (retained for backward-compat with the pre-§4.2
360
- // single `onChainOk`): false on rollup mismatch, true on confirm, null on
361
- // all-unreachable.
271
+ // Collapsed view: false on mismatch, true on confirm, null on all-unreachable.
362
272
  onChainOk: boolean | null;
363
273
  perGateway: PerGatewayOutcome[];
364
274
  }
@@ -366,55 +276,81 @@ export interface OnChainResult {
366
276
  export interface CheckpointResult {
367
277
  /** The derived `checkpoint_ref`, empty when it could not be derived. */
368
278
  checkpointRef: string;
369
- /**
370
- * Where this checkpoint was witnessed. `null` when the checkpoint carries no
371
- * `anchor_ref`: placed but not yet witnessed, which is a normal state and NOT
372
- * a deficiency.
373
- */
279
+ /** Where it was witnessed; `null` when placed but not yet witnessed, which is normal. */
374
280
  anchorRef: AnchorRef | null;
375
- // The checkpoint envelope verified (signature + payload binding to its record).
281
+ // The checkpoint envelope verified (signature and payload binding).
376
282
  envelopeOk: boolean;
377
283
  // The committed record's merkle_root equals the checkpoint's claimed merkle_root.
378
284
  merkleRootOk: boolean;
379
- // If gateways were supplied: the on-chain bytes at tx_id matched the envelope.
380
- // null when no gateway re-fetch was requested, or when `anchor_ref.kind` has
381
- // no witness adapter, or its id is not one the adapter accepts; the witness
382
- // was not checked. Derived collapse of `onChain` (§4.2), retained so existing
383
- // consumers keep the single-boolean field.
285
+ // Collapsed `onChain`. null when the witness was not checked: no gateways, no
286
+ // witness adapter for `anchor_ref.kind`, or an id the adapter rejects.
384
287
  onChainOk: boolean | null;
385
- // Per-gateway on-chain outcomes (§4.2), ADDITIVE to `onChainOk`. null when no
386
- // gateway re-fetch was requested (offline-only verification), or when
387
- // `anchor_ref.kind` has no witness adapter, or its id is not one the adapter
388
- // accepts; the witness was not checked.
288
+ // Per-gateway outcomes (§4.2); null when the witness was not checked.
389
289
  onChain: OnChainResult | null;
390
290
  ok: boolean;
391
291
  errors: string[];
292
+ // True where the only thing wrong is a malformed `anchor_ref`: the bytes the pack
293
+ // signed hold and break their own format (`follows_rules` disproven).
294
+ anchorRefMalformed?: boolean;
392
295
  }
393
296
 
394
297
  export interface EventResult {
395
298
  eventId: string;
396
- // The event envelope verified (signature ok; payload binding ok OR undetermined).
299
+ // Signature ok, and payload binding ok or undetermined.
397
300
  envelopeOk: boolean;
398
- // payload binding: true matched the committed record, false mismatch,
399
- // null record withheld (semantics-undetermined — NOT a failure, §3.1/§6.2).
301
+ // true matched, false mismatch, null record withheld: undetermined, not a
302
+ // failure (envelope-spec §3.1, §6 item 2).
400
303
  payloadBindingOk: boolean | null;
401
304
  // RFC 9162 inclusion of this event's leaf in its checkpoint's root.
402
305
  inclusionOk: boolean;
403
306
  // The inclusion's checkpoint_ref resolved to a checkpoint in the bundle.
404
307
  checkpointBound: boolean;
405
- // Raw-log (content) binding: SHA-256 of the disclosed bytes equals the
406
- // committed event.content_hash (in event_bytes), or, for a promoted
407
- // envelope, a hash it commits to. true matched, false mismatch (→ event
408
- // fails), null undetermined (nothing disclosed OR nothing committed OR record
409
- // withheld — NOT a failure, mirroring payloadBindingOk).
308
+ // SHA-256 of the disclosed bytes equals the committed `content_hash` (or, for
309
+ // a promoted envelope, a hash it commits to). false fails the event; null is
310
+ // undetermined (nothing disclosed or committed, or record withheld).
410
311
  contentOk: boolean | null;
411
312
  ok: boolean;
412
313
  errors: string[];
314
+ /**
315
+ * The `sanning.agent` reading of this record (agent-events.md section 10), read with
316
+ * every other record of the pile it was verified in: the bundle, or every pack
317
+ * of a case. `null` where the record is not an agent record, or its envelope
318
+ * did not verify, or its record bytes did not travel or did not bind. It never
319
+ * moves `ok` or the bundle status; the pack grade reads it (section 5.4.5).
320
+ */
321
+ agent: AgentRecordReading | null;
322
+ /**
323
+ * True where the record's `stamp_ref` or `checkpoint_ref` names a stamp or checkpoint the
324
+ * pack does not hold: `witnessed` is not proven (`missing`), never disproven, whatever else
325
+ * about the record fails.
326
+ */
327
+ stampAbsent?: boolean;
328
+ /**
329
+ * True where the record carries a retired field name, or bytes that are present and cannot
330
+ * be read: a defect in the artifact that fails the record under a key that can be read.
331
+ */
332
+ ownFormFailed?: boolean;
333
+ /**
334
+ * True where the record's signed bytes bind to its `payload_hash` under a signature that
335
+ * holds and are not JSON: the producer broke the format it signed, so `follows_rules` is
336
+ * disproven and nothing was proven altered.
337
+ */
338
+ recordNotJson?: boolean;
339
+ /**
340
+ * True where the bytes this record's content was checked against came from the side
341
+ * input, which nobody signed: a mismatch then leaves `unchanged` not proven
342
+ * (`not_checked`), never disproven.
343
+ */
344
+ contentUnsigned?: boolean;
345
+ /** Why the record's envelope does not verify: the worst of `envelopeFaults`, or `null`. */
346
+ envelopeFault?: EnvelopeFault | null;
347
+ /** Every envelope fault found (`ENVELOPE_FAULTS`), worst first. */
348
+ envelopeFaults?: EnvelopeFault[];
413
349
  }
414
350
 
415
351
  /** One sub-tree's recomputed result (§5.2). */
416
352
  export interface StampSubtreeResult {
417
- /** The blinded key the body spells it under. Meaningless without the org's secret. */
353
+ /** The blinded key the body uses. */
418
354
  ref: string;
419
355
  firstLeaf: number;
420
356
  leafCount: number;
@@ -423,17 +359,13 @@ export interface StampSubtreeResult {
423
359
 
424
360
  /** One `stamps[]` entry's result (§5.2). */
425
361
  export interface StampResult {
426
- /** The stamp's derived identity, as the bundle spells it. "" when unusable. */
362
+ /** The stamp's derived identity. "" when unusable. */
427
363
  stampRef: string;
428
364
  /** The asserted `stamp_ref` equals SHA-256 of the decoded `stamp_bytes`. */
429
365
  stampRefOk: boolean;
430
366
  /**
431
- * The witnessed bytes decoded, parsed, and re-canonicalized to themselves.
432
- *
433
- * The last clause is not pedantry. The plane stores these bytes as TEXT and
434
- * replays them verbatim precisely because a round-trip through a normalizing
435
- * serializer is not guaranteed to reproduce what was signed; a body that is
436
- * not JCS-canonical is one that every re-deriving reader hashes differently.
367
+ * The witnessed bytes decode, parse and re-canonicalize to themselves. A body
368
+ * that is not JCS-canonical hashes differently for every re-deriving reader.
437
369
  */
438
370
  bodyOk: boolean;
439
371
  stampVersion: string | null;
@@ -442,55 +374,34 @@ export interface StampResult {
442
374
  merkleRoot: string | null;
443
375
  leafCount: number | null;
444
376
  /**
445
- * Every sub-tree range accounts for exactly one leaf of the stamp: the
446
- * ranges sorted by `first_leaf` tile `[0, leaf_count)` with no gap and no
447
- * overlap, and their counts sum to `leaf_count`.
377
+ * The sub-tree ranges, sorted by `first_leaf`, tile `[0, leaf_count)` with no
378
+ * gap or overlap.
448
379
  */
449
380
  subtreesOk: boolean;
450
381
  subtrees: StampSubtreeResult[];
451
382
  /**
452
- * Where it was witnessed, or `null` for a stamp nothing has witnessed yet.
453
- *
454
- * `null` is NOT a failure and never an accusation — it is the honest state
455
- * of every stamp for the first minutes of its life and the permanent state
456
- * of every stamp when no witness is configured. It does hold the bundle at
457
- * `partial`: a stamp is Sanning's own assertion until something independent
458
- * carries it.
383
+ * Where it was witnessed, or `null` if nothing has witnessed it yet. Never an
384
+ * accusation, but it holds the bundle at `partial`: until witnessed, a stamp
385
+ * is Sanning's own assertion.
459
386
  */
460
387
  anchorRef: AnchorRef | null;
461
388
  /**
462
- * Per-gateway on-chain outcomes for this stamp's `anchor_ref` (§4.2's shape,
463
- * `checkpoints[]`'s vocabulary), or `null` when NOTHING WAS CHECKED.
464
- *
465
- * ── The four states, and why none of them collapses ──
466
- *
467
- * `null` means not checked — no gateways were supplied, or this stamp names
468
- * no witness to check, or its `anchor_ref.kind` has no witness adapter, or
469
- * its id is not one the adapter accepts; the witness was not checked. It is
470
- * a statement about the RUN, never about the stamp. The other three are
471
- * statements about the stamp and live in `onChain.rollup`: `confirm` (a
472
- * gateway returned the witnessed bytes), `mismatch` (a gateway returned
473
- * DIFFERENT bytes: a finding, and the only one of the four that fails
474
- * anything), `unreachable` (we asked and nobody answered: an availability
475
- * gap, never an accusation).
476
- *
477
- * Keeping "not checked" distinct from "unreachable" is the whole point. A
478
- * surface handed a collapsed boolean cannot tell "no gateway could be
479
- * reached" from "no gateway was tried", and reported the first when the
480
- * second was true — a false accusation manufactured by a missing state.
389
+ * Per-gateway outcomes for this stamp's `anchor_ref`, or `null` when nothing
390
+ * was checked (a statement about the run, not the stamp). Of `confirm`,
391
+ * `mismatch` and `unreachable`, only `mismatch` fails anything. "Not checked"
392
+ * and "unreachable" stay distinct so a surface never reports an availability
393
+ * gap that was never tested.
481
394
  */
482
395
  onChain: OnChainResult | null;
483
396
  /**
484
- * Derived collapse of `onChain`, mirroring `CheckpointResult.onChainOk` so a
485
- * consumer reads one field the same way on both: `false` on mismatch, `true`
486
- * on confirm, `null` on unreachable AND on not-checked.
487
- *
488
- * It is deliberately the LOSSY view — `null` is three-quarters of the state
489
- * space here. Anything that needs to tell those apart reads `onChain`.
397
+ * Lossy collapse of `onChain`, as on `CheckpointResult`: `false` mismatch,
398
+ * `true` confirm, `null` unreachable or not checked.
490
399
  */
491
400
  onChainOk: boolean | null;
492
401
  ok: boolean;
493
402
  errors: string[];
403
+ /** True where the only thing wrong is a malformed `anchor_ref` (as on a checkpoint). */
404
+ anchorRefMalformed?: boolean;
494
405
  }
495
406
 
496
407
  export interface EvidenceBundleResult {
@@ -500,37 +411,43 @@ export interface EvidenceBundleResult {
500
411
  bodyHashOk: boolean;
501
412
  signatureOk: boolean;
502
413
  bodyType: string | null;
503
- // null when no gateways were supplied (offline-only verification).
504
414
  onChainChecked: boolean;
505
415
  checkpoints: CheckpointResult[];
506
416
  events: EventResult[];
507
- // The producer's asserted verdict.status (for display only — never trusted).
508
- assertedStatus: string | null;
509
- // Present ONLY for a `sanning.evidence.export/v2` body (evidence-export.md §5):
510
- // the export-specific dimensions (source linkage, verdict agreement, embedded
511
- // attestations, recomputed §4 verdict). The top-level `checkpoints`/`events`
512
- // above are the recomputed SOURCE-bundle results in that case. Absent for a
513
- // plain anchor-trace bundle — additive, backward-compatible.
417
+ // Only for a `sanning.evidence.export/v2` body (evidence-export.md §5). The
418
+ // top-level `checkpoints`/`events` are then the recomputed source bundle's.
514
419
  export?: ExportResult;
515
- // Present ONLY for a `sanning.stamp.trace/v1` body (evidence-bundle.md §5.2):
516
- // the per-stamp recomputed results. `checkpoints`/`events` stay empty for
517
- // such a bundle — a stamp body carries neither, and reporting an empty list
518
- // as "nothing was wrong" is the exact confusion `partial` exists to prevent.
519
- // Absent for every other body — additive, backward-compatible.
420
+ // Only for a `sanning.stamp.trace/v1` body (§5.2). `checkpoints` and `events`
421
+ // stay empty: a stamp body carries neither.
520
422
  stamps?: StampResult[];
521
423
  errors: string[];
522
- // Informational only: never read, never gated on, never moves `status` or
523
- // the CLI exit code. Carries `CO_SIGNATURES_NOTE` where the wrapper's
524
- // reserved `co_signatures` field is present with at least one entry
525
- // (evidence-bundle.md §2, §7.1 decision proof#148). Absent for every
526
- // bundle without it, additive and backward-compatible.
424
+ // True where the bundle could not be read as one (the claim it blocks is not proven,
425
+ // `unreadable`): not a JSON object, an unknown major or `signature_alg`, a required
426
+ // wrapper field missing, or an export body that cannot be read. `status` is then
427
+ // `incomplete`.
428
+ unreadable?: boolean;
429
+ // True where the wrapper's `body_hash` and signature hold and its `generated_at` has
430
+ // another form: the producer broke the format it signed (`follows_rules` disproven).
431
+ // `status` is then `failed`, and the body is still read, so a disproof inside it stands.
432
+ // Under a key that cannot be read the form is not judged.
433
+ generatedAtMalformed?: boolean;
434
+ // True where the wrapper's `public_key` cannot be read as a key: `signed` is not proven
435
+ // (`unreadable`). `status` is `failed` where `body_hash` does not hold, and otherwise the
436
+ // body's own reading, never above `incomplete`.
437
+ keyUnreadable?: boolean;
438
+ // True where the wrapper's `spec_version` names a major this verifier does not know:
439
+ // the body is not read, and `follows_rules` is not proven (`not_checked`). The wrapper
440
+ // signature is still checked: where it fails, `status` is `failed` and `signatureOk` is
441
+ // false; where it holds, `signatureOk` is true, the only claim about the wrapper this
442
+ // verifier can make, and `bodyHashOk` stays false, unchecked.
443
+ versionUnknown?: boolean;
444
+ // Informational only: never moves `status` or the exit code. Carries
445
+ // `CO_SIGNATURES_NOTE` when the wrapper's `co_signatures` holds an entry.
527
446
  notes?: string[];
528
447
  }
529
448
 
530
- // ---- Attested-evidence-export wire + result types (evidence-export.md) -------
531
-
532
- // One embedded operator attestation record (§2.2 / §3). RSA-PSS-SHA-256 over
533
- // JCS(payload); the operator key is the operator's Arweave RSA wallet.
449
+ // One embedded operator attestation (§2.2, §3): RSA-PSS-SHA-256 over
450
+ // JCS(payload), keyed by the operator's Arweave RSA wallet.
534
451
  export interface AttestationRecord {
535
452
  /** The source checkpoint's derived `checkpoint_ref` this attestation binds to. */
536
453
  checkpoint_ref: string;
@@ -562,10 +479,8 @@ export interface ExportBody {
562
479
  [k: string]: unknown;
563
480
  }
564
481
 
565
- // ---- The §4 kernel verdict object (snake_case, spec-canonical computed wire) --
566
- // Recomputed by the kernel; the copy cached in body.kernel_verdict is a
567
- // rendering convenience the verifier recomputes and (over its deterministic
568
- // dimensions, §5 step 5) confirms.
482
+ // The §4 verdict object, recomputed by the kernel. The copy cached in
483
+ // `body.kernel_verdict` is a convenience the verifier checks (§5 step 5).
569
484
 
570
485
  export interface VerdictEvent {
571
486
  event_id: string;
@@ -621,87 +536,70 @@ export interface AttestationResult {
621
536
  checkpointRef: string;
622
537
  operator: string;
623
538
  gateway: string | null;
624
- // The RSA-PSS-SHA-256 signature over JCS(payload) verified (§5 step 6a).
539
+ // RSA-PSS-SHA-256 over JCS(payload) verified (§5 step 6a).
625
540
  signatureOk: boolean;
626
- // base64url(SHA-256(public_key.n)) == payload.operator (§3.3 / step 6b).
541
+ // base64url(SHA-256(public_key.n)) == payload.operator (§5 step 6b).
627
542
  operatorAddressBound: boolean;
628
- // payload.data_hash == the resolved source checkpoint's committed content hash
629
- // (SHA-256(JCS(checkpoint.envelope))) (§5 step 6c).
543
+ // payload.data_hash == SHA-256(JCS(source checkpoint envelope)) (§5 step 6c).
630
544
  dataHashBound: boolean;
631
545
  // checkpoint_ref resolved to a present source checkpoint.
632
546
  checkpointResolved: boolean;
633
547
  level: number | null;
634
- // subject_ref (§3.2) tri-state: null absent/undetermined, true/false only
635
- // against a supplied side-input subject. Never gates the attestation.
548
+ // subject_ref (§3.2): null absent or undetermined; true/false only against a
549
+ // supplied subject. Never gates the attestation.
636
550
  subjectRefOk: boolean | null;
637
551
  ok: boolean;
638
552
  errors: string[];
553
+ // Set where the signature verifies and a payload field breaks section 3.1: the issuer
554
+ // signed a record that does not follow its format (`follows_rules` disproven).
555
+ fieldError?: string | null;
639
556
  }
640
557
 
641
558
  export interface ExportResult {
642
559
  // SHA-256(JCS(source_bundle)) == source_bundle_hash (§5 step 3).
643
560
  sourceLinkageOk: boolean;
644
- // Recomputed source-bundle verdict status (verifyEvidenceBundle over the
645
- // inline source_bundle, §5 step 4).
561
+ // Recomputed source-bundle status (§5 step 4).
646
562
  sourceStatus: EvidenceStatus;
647
- // Cached-vs-recomputed verdict agreement over the DETERMINISTIC, offline-
648
- // recomputable dimensions only (§5 step 5 + the on-chain-is-informational
649
- // erratum — see verifyExportBody). The on-chain per-gateway dimension is
650
- // environment/time-dependent and is EXCLUDED from this comparison.
563
+ // Cached-vs-recomputed agreement over the deterministic, offline dimensions
564
+ // only (§5 step 5). The on-chain dimension depends on time and environment.
651
565
  verdictAgreementOk: boolean;
652
566
  attestations: AttestationResult[];
653
- // The freshly recomputed §4 verdict object (snake_case) — what renderers and
654
- // the verify API display (never the cached copy).
567
+ // The freshly recomputed §4 verdict: what surfaces display, never the cache.
655
568
  verdict: VerdictObject;
656
- // The export's own rollup status; drives the CLI exit code.
569
+ // The export's own rollup; drives the CLI exit code.
657
570
  status: EvidenceStatus;
571
+ // True where the inline source bundle cannot be read while `source_bundle_hash` holds
572
+ // and the export wrapper verifies: the issuer signed a source that does not follow its
573
+ // format (`follows_rules` disproven), and nothing was proven altered.
574
+ sourceUnreadable?: boolean;
658
575
  }
659
576
 
660
577
  export interface VerifyEvidenceOptions {
661
- // Gateways to re-fetch each anchor_ref.id from (first responsive wins).
662
- // When supplied, the on-chain bytes MUST byte-match the bundle's checkpoint
663
- // envelope, proving the proof is anchored, not just locally asserted.
578
+ // Gateways to re-fetch each `anchor_ref.id` from (first responsive wins). The
579
+ // on-chain bytes must byte-match the checkpoint envelope.
664
580
  gateways?: string[];
665
- // Out-of-band disclosed raw bytes, keyed by event_id, to bind against each
666
- // event's committed content_hash (the rawLog → content_hash link). A
667
- // Uint8Array is used as-is; a string is parsed as lowercase hex. In-body
668
- // `events[].content` (signed) takes precedence over this side input; if both
669
- // are present for an event and disagree, the event fails (§5.1).
670
- //
671
- // A `Map` is the SAFE shape and the one to prefer — it is what
672
- // `disclosedContent` hands back, so our output feeds straight back in, and a
673
- // Map keys on the string itself: no prototype to inherit from and no
674
- // `__proto__` write that silently lands somewhere else. The plain `Record` is
675
- // still accepted (it was the original signature and remains supported), and
676
- // the lookup guards it — see `lookupSideContent`. Widening is additive: every
677
- // existing caller passing a Record keeps working unchanged.
581
+ // Out-of-band disclosed bytes by event_id, bound against each event's
582
+ // committed `content_hash`. A Uint8Array is used as-is; a string is hex.
583
+ // In-body `events[].content` (signed) wins; if both are present and disagree,
584
+ // the event fails (§5.1). Prefer a `Map`: a plain object is guarded (see
585
+ // `./side-content.ts`), but a Map has no prototype to inherit from.
678
586
  content?: Record<string, Uint8Array | string> | ReadonlyMap<string, Uint8Array | string>;
679
- // Injectable fetch (Node >=18 global `fetch` by default) — test seam.
587
+ // Injectable fetch (global `fetch` by default).
680
588
  fetchImpl?: typeof fetch;
681
589
  }
682
590
 
683
591
  /**
684
- * A surface MUST NOT present a wrapper's `co_signatures` as evidence
685
- * (evidence-bundle.md §2, decided by Phil 2026-09-24 on proof#148): nothing
686
- * in either kernel verifies the field, and it sits outside the wrapper's
687
- * signed scope (`bundleForSig` below), so anyone holding a pack's bytes can
688
- * append it with no key at all. This is the one sentence both kernels attach
689
- * as a note, never a verdict, never an exit code.
592
+ * A wrapper's `co_signatures` is not evidence (evidence-bundle.md §2): nothing
593
+ * verifies it, and it sits outside the signed scope, so anyone can append it.
594
+ * Attached as a note, never a verdict or exit code.
690
595
  */
691
596
  export const CO_SIGNATURES_NOTE =
692
597
  "unverified co-signatures are present in this pack's wrapper and are not part of the evidence";
693
598
 
694
599
  /**
695
- * True iff the wrapper's reserved `co_signatures` field carries at least one
696
- * entry.
697
- *
698
- * Absence and an empty array read the same way here: the field asserts
699
- * nothing about a second signer until it holds one, and evidence-bundle.md's
700
- * own carve-out language for the analogous envelope field treats "absent"
701
- * and "an empty array" as one thing (envelope-spec.md §7.1). A non-array
702
- * value (malformed shape, but the field is reserved, not yet specified)
703
- * still counts as present: it is not the well-formed absence either rule
704
- * describes.
600
+ * Whether the wrapper's reserved `co_signatures` carries at least one entry.
601
+ * Absent and empty read the same (envelope-spec.md §7.1); a non-array value
602
+ * counts as present.
705
603
  */
706
604
  export function coSignaturesPresent(b: unknown): boolean {
707
605
  if (b === null || typeof b !== "object" || Array.isArray(b)) return false;
@@ -727,24 +625,20 @@ function isEvidenceMajorAccepted(specVersion: unknown): boolean {
727
625
  });
728
626
  }
729
627
 
730
- /**
731
- * A bundle this kernel cannot read.
732
- *
733
- * `wrapperOk` is true only where the wrapper signature and `body_hash` were
734
- * CHECKED and held before the body was found unreadable. It is then reported
735
- * as held, because it did: `signatureOk` and `bodyHashOk` are statements about
736
- * the wrapper, and a body this kernel cannot parse does not unsign it. The
737
- * case reader decides whose pack a bundle is on exactly those two fields
738
- * (`evidence-bundle.md` 5.5.1), so reporting `false` here let a holder who
739
- * broke its own body shape read as a forger.
740
- */
741
- /** evidence-bundle §2 (A31). Byte-identical to the Python kernel's. */
628
+ /** evidence-bundle.md §2. Byte-identical to the Python kernel's. */
742
629
  export const GENERATED_AT_MALFORMED =
743
- "malformed generated_at: not an RFC 3339 UTC date-time with Z naming a real date and time (evidence-bundle §2)";
630
+ "malformed generated_at: not an RFC 3339 UTC date-time with Z naming a real date and time (evidence-bundle section 2)";
744
631
 
632
+ /**
633
+ * A bundle this kernel cannot read. `wrapperOk` is true only where the wrapper
634
+ * signature and `body_hash` were checked and held: an unparseable body does not
635
+ * unsign the wrapper, and the case reader decides whose pack it is on those
636
+ * fields (§5.5.1).
637
+ */
745
638
  function malformed(error: string, specVersionOk = false, wrapperOk = false): EvidenceBundleResult {
746
639
  return {
747
- status: "malformed",
640
+ status: "incomplete",
641
+ unreadable: true,
748
642
  specVersionOk,
749
643
  bodyHashOk: wrapperOk,
750
644
  signatureOk: wrapperOk,
@@ -752,82 +646,87 @@ function malformed(error: string, specVersionOk = false, wrapperOk = false): Evi
752
646
  onChainChecked: false,
753
647
  checkpoints: [],
754
648
  events: [],
755
- assertedStatus: null,
756
649
  errors: [error],
757
650
  };
758
651
  }
759
652
 
760
- /**
761
- * The prefixes of the exception text this kernel writes ITSELF, in the words
762
- * the Python kernel writes too. Anything outside this set comes from the host:
763
- * a JSON parser, a codec, a crypto library. See `thrownDetail`.
764
- */
765
- // `duplicate key ` is `DuplicateKeyError` in `verifier.ts`, which
766
- // `DuplicateKey` in the Python twin's `canonicalize.py` words identically.
653
+ // The prefixes of exception text this kernel writes itself, identically in
654
+ // Python. Anything else comes from the host. See `thrownDetail`.
767
655
  const KERNEL_WORDING = ["hexToBytes: ", "duplicate key "];
768
656
 
769
657
  /**
770
- * The detail a finding may append about a caught exception.
771
- *
772
- * THE RULE: a finding carries the text of a caught exception only where this
773
- * kernel wrote that text. A finding travels into a case reading through `case`
774
- * `reasons`, and both kernels promise one reading and one rendered sentence for
775
- * one set of bytes; a host JSON parser says `Unexpected token 'h', "hello" is
776
- * not valid JSON` here and `Expecting value: line 1 column 1 (char 0)` there,
777
- * for one pack anyone can build (gate 4 on proof#140, finding 1).
778
- *
779
- * Returns `": <text>"` for this kernel's own wording and the empty string for
780
- * anything else. The label at the call site already names the check that
781
- * failed and both kernels agree on it character for character; what is dropped
782
- * is the host's parse offset, which no reader can act on and which was never
783
- * the same in the two kernels.
658
+ * The record as the agent reader holds it, or `null` where it takes no part:
659
+ * only a `sanning.events/v2` record whose envelope verified and whose bytes
660
+ * bound to it (section 10).
784
661
  */
662
+ function agentItem(ev: { envelope?: unknown }, eventBytes: Uint8Array | undefined, bound: boolean): AgentHeldRecord | null {
663
+ if (!bound || eventBytes === undefined) return null;
664
+ const envelope = ev.envelope;
665
+ if (!isObject(envelope) || !specVersionSupported(envelope.spec_version as string, AGENT_RECORD_PROFILES)) {
666
+ return null;
667
+ }
668
+ let record: unknown;
669
+ try {
670
+ record = jcsParse(eventBytes);
671
+ } catch {
672
+ return null;
673
+ }
674
+ return {
675
+ event_id: envelope.event_id as string,
676
+ public_key: envelope.public_key as string,
677
+ record,
678
+ };
679
+ }
680
+
681
+ // The profile whose records the agent vocabulary governs (agent-events.md section 3).
682
+ const AGENT_RECORD_PROFILES = ["sanning.events/v2"];
683
+
684
+ /** Read the bundle's records as one pile and put each agent record's reading on its result. */
685
+ async function attachAgentReadings(
686
+ results: EventResult[],
687
+ items: readonly (AgentHeldRecord | null)[],
688
+ ): Promise<void> {
689
+ const pile = items.filter((item): item is AgentHeldRecord => item !== null);
690
+ const readings = await readAgentRecords(pile);
691
+ let next = 0;
692
+ for (const [i, item] of items.entries()) {
693
+ if (item !== null) results[i]!.agent = readings[next++]!;
694
+ }
695
+ }
696
+
785
697
  /**
786
- * The kernel's `duplicate key "<name>"` when a record repeats a member name,
787
- * else `undefined` (proof#144; `_record_duplicate_key` in the Python twin).
788
- *
789
- * ONLY A DUPLICATE, and not every unreadable record. Every reader below this
790
- * one that parses a record treats an unreadable one as absent, so the content
791
- * binding reads undetermined and the pack said "1 record discloses no bytes"
792
- * about a record whose bytes it had read. A record that repeats a name is
793
- * a defect in the artifact, and a defect this kernel can see fails, the rule
794
- * `event_bytes` that are not hex already follow.
698
+ * The finding for record bytes a producer signed that are not JSON, else
699
+ * `undefined` (`_record_not_json` in the Python twin): `event record is not
700
+ * JSON`, with the kernel's `duplicate key "<name>"` where the bytes repeat a
701
+ * member name. The bytes are signed, so the defect is the producer's and
702
+ * visible, and the record fails (evidence-bundle.md section 4), never reads as
703
+ * withheld.
795
704
  */
796
- function recordDuplicateKey(eventBytes: Uint8Array | undefined): string | undefined {
705
+ function recordNotJson(eventBytes: Uint8Array | undefined): string | undefined {
797
706
  if (eventBytes === undefined) return undefined;
798
707
  try {
799
708
  jcsParse(eventBytes);
800
709
  } catch (e) {
801
- if (e instanceof DuplicateKeyError) return e.message;
710
+ return `event record is not JSON${thrownDetail(e)}`;
802
711
  }
803
712
  return undefined;
804
713
  }
805
714
 
715
+ /**
716
+ * The detail a finding may append about a caught exception: `": <text>"` for
717
+ * this kernel's own wording, `""` for anything else. Host parser messages
718
+ * differ by kernel, and both kernels must render one sentence for one set of
719
+ * bytes.
720
+ */
806
721
  function thrownDetail(e: unknown): string {
807
722
  const text = e instanceof Error ? e.message : String(e);
808
723
  return KERNEL_WORDING.some((w) => text.startsWith(w)) ? `: ${text}` : "";
809
724
  }
810
725
 
811
726
  /**
812
- * Decode `bytes` as UTF-8 the way every other kernel does: a byte sequence that
813
- * is not valid UTF-8 THROWS, and a leading byte-order mark is kept rather than
814
- * eaten.
815
- *
816
- * ONE CALLER LEFT, and it is not a parse. `jcsParse` (`verifier.ts`) reads
817
- * every set of signed bytes in this kernel now; this function survives only for
818
- * the canonicality comparison on the stamp path, which needs the DECODED TEXT
819
- * of bytes `jcsParse` has already accepted, to compare against `jcs(body)`. It
820
- * performs exactly the decode `jcsParse` performs, so the comparison cannot
821
- * read the bytes differently from the parse that produced `body`.
822
- *
823
- * `new TextDecoder()` alone is neither strict nor BOM-preserving: it replaces
824
- * every invalid sequence with U+FFFD and strips a BOM. A stamp body carrying
825
- * one raw `0x80` inside a string value read `verified` here and `failed` there
826
- * because of it (gate 4 on proof#140). The symmetric claim the first version of
827
- * this comment made, that "Python's `json.loads` raises on both", is FALSE and
828
- * is what hid gate 5's defect for a round: `json.loads(<bytes>)` strips a BOM
829
- * too. Python's strict reader is `jcs_loads`, and the rule both kernels hold to
830
- * is stated once, in `jcsParse`.
727
+ * Decode `bytes` as strict UTF-8, keeping a leading BOM: the decode `jcsParse`
728
+ * performs, for the stamp path's canonicality comparison on bytes `jcsParse`
729
+ * already accepted.
831
730
  */
832
731
  function decodeUtf8Strict(bytes: Uint8Array): string {
833
732
  return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
@@ -835,12 +734,59 @@ function decodeUtf8Strict(bytes: Uint8Array): string {
835
734
 
836
735
  /**
837
736
  * Name an envelope `public_key` or `signature` spelled outside the hex charset
838
- * or at the wrong length (proof#142). This kernel's envelope layer already
839
- * fails such an envelope, and the Python one's decoder accepts ASCII
840
- * whitespace (proof#135), so both bundle verifiers name it here, in the same
841
- * words, and the two kernels give one reading. Only a STRING is checked: a
842
- * missing or non-string field is the envelope layer's to fail, and it does.
737
+ * or at the wrong length, in the same words as Python, so both kernels give one
738
+ * reading. Only a string is checked; a missing one is the envelope layer's to
739
+ * fail.
740
+ */
741
+ /**
742
+ * The envelope faults that leave a claim not proven rather than disproven: the record's
743
+ * envelope or key cannot be read, or its version is one this verifier does not know.
744
+ */
745
+ export const UNPROVEN_ENVELOPE_FAULTS: readonly EnvelopeFault[] = [
746
+ "not_an_envelope",
747
+ "key_unreadable",
748
+ "spec_version_unknown",
749
+ ];
750
+
751
+ /**
752
+ * A record that fails only on something not proven: a stamp or checkpoint the pack does not
753
+ * hold, an envelope or key that cannot be read, or a version this verifier does not know,
754
+ * while every check that runs without it holds (the binding, the inclusion proof and the
755
+ * content). It holds the bundle at `incomplete`, never `failed`.
843
756
  */
757
+ function unproven(e: EventResult): boolean {
758
+ const envelopeUnproven =
759
+ e.envelopeFault !== undefined && e.envelopeFault !== null && UNPROVEN_ENVELOPE_FAULTS.includes(e.envelopeFault);
760
+ const stampAbsent = e.stampAbsent === true;
761
+ return (
762
+ (stampAbsent || envelopeUnproven) &&
763
+ (e.envelopeOk || envelopeUnproven) &&
764
+ (envelopeUnproven || e.ownFormFailed !== true) &&
765
+ e.payloadBindingOk !== false &&
766
+ e.contentOk !== false &&
767
+ (stampAbsent || (e.inclusionOk && e.checkpointBound))
768
+ );
769
+ }
770
+
771
+ /**
772
+ * Why a record's envelope does not verify, worst first: the envelope layer's own faults,
773
+ * and, where only this kernel's strict hex reading refuses a field, a key that cannot be read
774
+ * or a signature that does not hold. A field's form is not judged under either.
775
+ */
776
+ function eventEnvelopeFaults(faults: readonly EnvelopeFault[], hexError: string | null): EnvelopeFault[] {
777
+ const found = new Set<EnvelopeFault>(faults);
778
+ if (hexError !== null) {
779
+ found.delete("nonconforming");
780
+ if (hexError.includes("public_key")) {
781
+ found.delete("signature_failed");
782
+ found.add("key_unreadable");
783
+ } else {
784
+ found.add("signature_failed");
785
+ }
786
+ }
787
+ return ENVELOPE_FAULTS.filter((f) => found.has(f));
788
+ }
789
+
844
790
  function envelopeHexError(what: string, envelope: unknown): string | null {
845
791
  if (!isJsonObject(envelope)) return null;
846
792
  for (const [name, nibbles] of [
@@ -856,34 +802,24 @@ function envelopeHexError(what: string, envelope: unknown): string | null {
856
802
  }
857
803
 
858
804
  /**
859
- * Spell a value inside a finding the way the Python kernel spells it.
860
- *
861
- * `JSON.stringify(undefined)` is `undefined` (the value, not a string), so an
862
- * absent field interpolates as the word `undefined` where Python writes `null`.
863
- * One word, and a real difference in what an auditor reads: someone comparing
864
- * the two CLIs on one bundle sees two different sentences for one fact. Both
865
- * kernels route every quoted value through this spelling, and the cross-kernel
866
- * anchor-trace leg byte-compares them.
805
+ * Spell a value inside a finding as Python does: an absent value reads `null`,
806
+ * never `undefined`.
867
807
  */
868
808
  function jsonStr(value: unknown): string {
869
809
  return JSON.stringify(value === undefined ? null : value) ?? "null";
870
810
  }
871
811
 
872
- // Verify a `sanning.evidence/v1` bundle and dispatch on its `body_type`.
873
- //
874
- // Returns a structured per-checkpoint + per-event result and a recomputed
875
- // rollup `status`. The producer's asserted `verdict.status` is surfaced
876
- // (`assertedStatus`) but NEVER trusted — the displayed status is the one this
877
- // function recomputes (evidence-bundle §1 principle 3 / §4 step 4).
812
+ /**
813
+ * Verify a `sanning.evidence/v1` bundle, dispatching on its `body_type`. The
814
+ * returned `status` is recomputed from the body; a wrapper `verdict` member,
815
+ * where a producer still writes one, is never read (evidence-bundle.md section 4).
816
+ */
878
817
  export async function verifyEvidenceBundle(
879
818
  bundle: unknown,
880
819
  options: VerifyEvidenceOptions = {},
881
820
  ): Promise<EvidenceBundleResult> {
882
- // The ONE place a present `co_signatures` becomes a note, so every caller
883
- // (including `case.ts`, which calls this function directly per pack) gets
884
- // it, however the wrapper otherwise verifies. Never touches `status` or
885
- // the exit code: a bundle that would otherwise verify still verifies, note
886
- // or not (proof#148).
821
+ // The one place a present `co_signatures` becomes a note, so `case.ts` gets
822
+ // it too. Never touches `status`.
887
823
  const result = await verifyBundle(bundle, options, false);
888
824
  const note = coSignaturesNote(bundle);
889
825
  if (note !== undefined) {
@@ -893,20 +829,18 @@ export async function verifyEvidenceBundle(
893
829
  }
894
830
 
895
831
  // `exportSource`: read the bundle as an export's source (evidence-export.md
896
- // §2.4), where the only recognised body is `sanning.anchor.trace/v3`.
832
+ // §2.4), where only `sanning.anchor.trace/v3` is recognised.
897
833
  async function verifyBundle(
898
834
  bundle: unknown,
899
835
  options: VerifyEvidenceOptions,
900
836
  exportSource: boolean,
901
837
  ): Promise<EvidenceBundleResult> {
902
- // --- Step 1: parse + reject unknown spec_version major ---------------------
838
+ // Step 1: parse and reject an unknown spec_version major.
903
839
  if (bundle === null || typeof bundle !== "object" || Array.isArray(bundle)) {
904
840
  return malformed("evidence bundle is not a JSON object");
905
841
  }
906
- // Reject a pathologically deep bundle BEFORE any canonicalization (which
907
- // recurses). A fixed shared bound keeps this a `malformed` verdict in every
908
- // kernel rather than a stack-limit-dependent split verdict — see
909
- // MAX_CANONICAL_DEPTH.
842
+ // Reject a pathologically deep bundle before canonicalization recurses (see
843
+ // MAX_CANONICAL_DEPTH).
910
844
  if (exceedsDepth(bundle, MAX_CANONICAL_DEPTH)) {
911
845
  return malformed(
912
846
  `evidence bundle nesting exceeds the ${MAX_CANONICAL_DEPTH}-level canonicalization depth bound`,
@@ -915,15 +849,38 @@ async function verifyBundle(
915
849
  const b = bundle as EvidenceBundle;
916
850
  const specVersionOk = isEvidenceMajorAccepted(b.spec_version);
917
851
  if (!specVersionOk) {
918
- // `?? null`: an ABSENT field is spelled `null`, as the Python twin's
919
- // `_json_str` spells it. `JSON.stringify(undefined)` is `undefined`, and
920
- // the two kernels printed two different sentences for one bundle until
921
- // `verify --json` compared them byte for byte (0.13.0).
922
- return malformed(`unsupported evidence spec_version: ${JSON.stringify(b.spec_version ?? null)}`, false);
852
+ // An absent field is spelled `null`, as in Python.
853
+ const unknown = malformed(
854
+ `unsupported evidence spec_version: ${JSON.stringify(b.spec_version ?? null)}`,
855
+ false,
856
+ );
857
+ unknown.versionUnknown = typeof b.spec_version === "string";
858
+ if (unknown.versionUnknown && typeof b.public_key === "string" && typeof b.signature === "string") {
859
+ // A version this verifier does not know still carries a signature it can check: one
860
+ // that fails is a pack changed after it was signed.
861
+ const found: string[] = [];
862
+ const [keyFormOk, signatureOk] = await checkWrapperSignature(b, found);
863
+ if (keyFormOk && !signatureOk) {
864
+ return {
865
+ status: "failed",
866
+ specVersionOk: false,
867
+ // Not checked under this version, so not the disproof here.
868
+ bodyHashOk: true,
869
+ signatureOk: false,
870
+ bodyType: null,
871
+ onChainChecked: false,
872
+ checkpoints: [],
873
+ events: [],
874
+ errors: [...unknown.errors, ...found],
875
+ versionUnknown: true,
876
+ };
877
+ }
878
+ unknown.signatureOk = signatureOk;
879
+ }
880
+ return unknown;
923
881
  }
924
882
  if (typeof b.signature_alg === "string" && b.signature_alg !== "ed25519") {
925
- // The reference verifier implements ed25519 only (evidence-bundle §2 +
926
- // decision #2); other algs are a deliberate future addition.
883
+ // Ed25519 only (evidence-bundle.md §2).
927
884
  return malformed(`unsupported signature_alg: ${JSON.stringify(b.signature_alg ?? null)}`, true);
928
885
  }
929
886
  if (typeof b.public_key !== "string" || typeof b.signature !== "string") {
@@ -933,22 +890,18 @@ async function verifyBundle(
933
890
  return malformed("evidence bundle is missing body_hash", true);
934
891
  }
935
892
  if (b.body === undefined || b.body === null) {
936
- // body_ref (out-of-line body) is a defined growth hook but not part of the
937
- // anchor-trace use case — refuse rather than silently pass.
893
+ // `body_ref` (out-of-line body) is refused rather than passed.
938
894
  return malformed("evidence bundle has no inline body (body_ref unsupported here)", true);
939
895
  }
940
896
 
941
- // --- Step 2: verify the wrapper signature + recompute body_hash ------------
897
+ // Step 2: verify the wrapper signature and recompute `body_hash`.
942
898
  const errors: string[] = [];
943
899
 
944
- // Every wrapper hex field is read STRICTLY, before any decoder sees it
945
- // (proof#142). The Python decoder skipped ASCII whitespace, and the wrapper
946
- // `signature` sits outside the signed scope, so one space in it, added by
947
- // anyone and with no key, made a pack verify there and fail here. The same
948
- // named failure in both kernels, for each field: `public_key` and
949
- // `signature` are 64 and 128 hex characters in either case (proof#135,
950
- // option B), and `body_hash` is 64 LOWER-case hex characters, the form it is
951
- // recomputed in and compared against.
900
+ // Every wrapper hex field is read strictly, before any decoder sees it:
901
+ // `signature` is outside the signed scope, so a lenient decoder would let
902
+ // anyone flip a verdict by adding whitespace. `public_key` and `signature`
903
+ // are 64 and 128 hex characters in either case; `body_hash` is 64 lowercase,
904
+ // the form it is recomputed in.
952
905
  let bodyHashOk = false;
953
906
  if (!isHexField(b.body_hash, 64, true)) {
954
907
  errors.push("wrapper body_hash is not 64 lowercase hex characters");
@@ -964,27 +917,35 @@ async function verifyBundle(
964
917
  }
965
918
  }
966
919
 
967
- let signatureOk = false;
968
- const keyFormOk = isHexField(b.public_key, 64);
969
- const signatureFormOk = isHexField(b.signature, 128);
970
- if (!keyFormOk) errors.push("wrapper public_key is not 64 hex characters");
971
- if (!signatureFormOk) errors.push("wrapper signature is not 128 hex characters");
972
- if (keyFormOk && signatureFormOk) {
973
- try {
974
- const { signature: _sig, co_signatures: _co, ...bundleForSig } = b as EvidenceBundle & {
975
- co_signatures?: unknown;
976
- };
977
- signatureOk = await ed25519Verify(utf8(jcs(bundleForSig)), b.signature, b.public_key);
978
- if (!signatureOk) errors.push("wrapper Ed25519 signature verification failed");
979
- } catch (e) {
980
- errors.push(`wrapper signature verification error${thrownDetail(e)}`);
981
- }
982
- }
920
+ const [keyFormOk, signatureOk] = await checkWrapperSignature(b, errors);
983
921
 
984
- const assertedStatus = typeof b.verdict?.status === "string" ? b.verdict.status : null;
922
+ // A key that cannot be read checks no signature: `signed` is not proven on that claim
923
+ // only. A `body_hash` that does not hold still fails, and a body that holds is still read,
924
+ // so a disproof inside it stands.
925
+ if (!keyFormOk && bodyHashOk) {
926
+ // Form is judged only under a signature that holds, so the wrapper's time is not.
927
+ const result = await readWrappedBody(b, specVersionOk, errors, options, exportSource, false);
928
+ result.signatureOk = false;
929
+ result.keyUnreadable = true;
930
+ if (result.status === "verified") result.status = "incomplete";
931
+ return result;
932
+ }
933
+ if (!keyFormOk) {
934
+ return {
935
+ status: "failed",
936
+ specVersionOk,
937
+ bodyHashOk,
938
+ signatureOk: false,
939
+ bodyType: typeof b.body_type === "string" ? b.body_type : null,
940
+ onChainChecked: false,
941
+ checkpoints: [],
942
+ events: [],
943
+ errors,
944
+ keyUnreadable: true,
945
+ };
946
+ }
985
947
 
986
- // A broken wrapper (bad signature or tampered body) is a hard failure — the
987
- // body is untrustworthy, so we do not pretend to verify its contents.
948
+ // A broken wrapper is a hard failure: the body is untrustworthy.
988
949
  if (!signatureOk || !bodyHashOk) {
989
950
  return {
990
951
  status: "failed",
@@ -995,102 +956,135 @@ async function verifyBundle(
995
956
  onChainChecked: false,
996
957
  checkpoints: [],
997
958
  events: [],
998
- assertedStatus,
999
959
  errors,
1000
960
  };
1001
961
  }
1002
962
 
1003
- // --- The wrapper's own time (evidence-bundle §2, A31) ---------------------
1004
- // `generated_at` is copied into a recomputed verdict's `as_of` (the export
1005
- // reading) and printed beside every verdict, so a present value that is not
1006
- // the RFC 3339 form `signed_at` already pins is a wrapper this kernel cannot
1007
- // render, whatever its signature. It is read only once the wrapper holds: a
1008
- // broken wrapper is `failed` above, as it was. Absence is unchanged.
963
+ return readWrappedBody(b, specVersionOk, errors, options, exportSource);
964
+ }
965
+
966
+ /** Everything a bundle says past its wrapper: its time, then its body by type. */
967
+ async function readWrappedBody(
968
+ b: EvidenceBundle,
969
+ specVersionOk: boolean,
970
+ errors: string[],
971
+ options: VerifyEvidenceOptions,
972
+ exportSource: boolean,
973
+ judgeTime = true,
974
+ ): Promise<EvidenceBundleResult> {
975
+ const result = await readBody(b, specVersionOk, errors, options, exportSource);
976
+ // The wrapper's `generated_at` (evidence-bundle.md §2) is printed beside every verdict
977
+ // and copied into `as_of`, so a present value that is not RFC 3339 UTC is a format the
978
+ // producer broke. The body is read first, so a disproof inside it stands beside this one.
1009
979
  if (
980
+ judgeTime &&
1010
981
  Object.prototype.hasOwnProperty.call(b, "generated_at") &&
1011
982
  !isRfc3339UtcInstant((b as { generated_at?: unknown }).generated_at)
1012
983
  ) {
1013
- return malformed(GENERATED_AT_MALFORMED, specVersionOk, true);
984
+ result.status = "failed";
985
+ result.generatedAtMalformed = true;
986
+ result.errors = [GENERATED_AT_MALFORMED, ...result.errors.filter((e) => e !== GENERATED_AT_MALFORMED)];
1014
987
  }
988
+ return result;
989
+ }
1015
990
 
1016
- // --- Body dispatch ---------------------------------------------------------
991
+ /**
992
+ * `[keyFormOk, signatureOk]` for the wrapper's Ed25519 signature over every field but
993
+ * `signature` and `co_signatures`. `public_key` and `signature` are strings here.
994
+ */
995
+ async function checkWrapperSignature(b: EvidenceBundle, errors: string[]): Promise<[boolean, boolean]> {
996
+ let signatureOk = false;
997
+ const keyFormOk = isHexField(b.public_key, 64);
998
+ const signatureFormOk = isHexField(b.signature, 128);
999
+ if (!keyFormOk) errors.push("wrapper public_key is not 64 hex characters");
1000
+ if (!signatureFormOk) errors.push("wrapper signature is not 128 hex characters");
1001
+ if (keyFormOk && signatureFormOk) {
1002
+ try {
1003
+ const { signature: _sig, co_signatures: _co, ...bundleForSig } = b as EvidenceBundle & {
1004
+ co_signatures?: unknown;
1005
+ };
1006
+ signatureOk = await ed25519Verify(utf8(jcs(bundleForSig)), b.signature, b.public_key);
1007
+ if (!signatureOk) errors.push("wrapper Ed25519 signature verification failed");
1008
+ } catch (e) {
1009
+ errors.push(`wrapper signature verification error${thrownDetail(e)}`);
1010
+ }
1011
+ }
1012
+ return [keyFormOk, signatureOk];
1013
+ }
1014
+
1015
+ /** A wrapped body, read by its type. */
1016
+ async function readBody(
1017
+ b: EvidenceBundle,
1018
+ specVersionOk: boolean,
1019
+ errors: string[],
1020
+ options: VerifyEvidenceOptions,
1021
+ exportSource: boolean,
1022
+ ): Promise<EvidenceBundleResult> {
1017
1023
  const bodyType = typeof b.body_type === "string" ? b.body_type : null;
1018
1024
 
1019
- // Read as an export's source (evidence-export.md §2.4), the only body this
1020
- // kernel recognises is sanning.anchor.trace/v3. Any other body, even one it
1021
- // verifies on its own, is undetermined here, after the wrapper checks above.
1025
+ // As an export's source, only sanning.anchor.trace/v3 is recognised; any
1026
+ // other body is undetermined.
1022
1027
  if (exportSource && bodyType !== ANCHOR_TRACE_BODY_TYPE) {
1023
1028
  errors.push(
1024
1029
  `body_type ${JSON.stringify(bodyType)} is not an export source ` +
1025
- `(only ${ANCHOR_TRACE_BODY_TYPE}); wrapper verified, body undetermined`,
1030
+ `(only ${ANCHOR_TRACE_BODY_TYPE}); wrapper verified, body not checked`,
1026
1031
  );
1027
1032
  return {
1028
- status: "partial",
1033
+ status: "incomplete",
1029
1034
  specVersionOk,
1030
- bodyHashOk,
1031
- signatureOk,
1035
+ bodyHashOk: true,
1036
+ signatureOk: true,
1032
1037
  bodyType,
1033
1038
  onChainChecked: false,
1034
1039
  checkpoints: [],
1035
1040
  events: [],
1036
- assertedStatus,
1037
1041
  errors,
1038
1042
  };
1039
1043
  }
1040
1044
 
1041
- // Attested-evidence-export body (evidence-export.md §5). The wrapper is
1042
- // authentic (Ed25519 issuer signature + body_hash verified above, §5 step 2);
1043
- // hand off to the export verifier for steps 3–9.
1045
+ // The wrapper is authentic (§5 step 2); the export verifier runs steps 3 to 9.
1044
1046
  if (bodyType === EXPORT_BODY_TYPE) {
1045
- return verifyExportBody(b, specVersionOk, assertedStatus, errors, options);
1047
+ return verifyExportBody(b, specVersionOk, errors, options);
1046
1048
  }
1047
1049
 
1048
- // The Sanning-signed interval stamp body (evidence-bundle.md §5.2). The
1049
- // wrapper is authentic — and for this body that IS "Sanning signed it", since
1050
- // a stamp carries no second signature of its own inside the bundle.
1050
+ // The stamp body (§5.2). For this body the wrapper signature is what
1051
+ // "Sanning signed it" means: a stamp carries no second signature in the bundle.
1051
1052
  if (bodyType === STAMP_TRACE_BODY_TYPE) {
1052
- return verifyStampTrace(b, specVersionOk, assertedStatus, errors, options);
1053
+ return verifyStampTrace(b, specVersionOk, errors, options);
1053
1054
  }
1054
1055
 
1055
- // The DL-52 hand-over body (§5.3): stamps AND the customer's events, joined.
1056
+ // The hand-over body (§5.3): stamps and the customer's events, joined.
1056
1057
  if (bodyType === STAMP_EVIDENCE_BODY_TYPE) {
1057
- return verifyStampEvidence(b, specVersionOk, assertedStatus, errors, options);
1058
+ return verifyStampEvidence(b, specVersionOk, errors, options);
1058
1059
  }
1059
1060
 
1060
1061
  if (bodyType !== ANCHOR_TRACE_BODY_TYPE) {
1061
- // The wrapper is authentic but we don't know this body's structure. Per
1062
- // wrap-don't-merge, that is "partial" (cryptographically sound wrapper,
1063
- // body verdict undetermined) — not a failure.
1062
+ // An authentic wrapper over an unknown body: `incomplete`, not a failure.
1064
1063
  errors.push(
1065
1064
  `body_type ${JSON.stringify(bodyType)} is not verifiable by this kernel ` +
1066
- `(only ${ANCHOR_TRACE_BODY_TYPE}); wrapper verified, body undetermined`,
1065
+ `(only ${ANCHOR_TRACE_BODY_TYPE}); wrapper verified, body not checked`,
1067
1066
  );
1068
1067
  return {
1069
- status: "partial",
1068
+ status: "incomplete",
1070
1069
  specVersionOk,
1071
- bodyHashOk,
1072
- signatureOk,
1070
+ bodyHashOk: true,
1071
+ signatureOk: true,
1073
1072
  bodyType,
1074
1073
  onChainChecked: false,
1075
1074
  checkpoints: [],
1076
1075
  events: [],
1077
- assertedStatus,
1078
1076
  errors,
1079
1077
  };
1080
1078
  }
1081
1079
 
1082
- return verifyAnchorTrace(b, specVersionOk, assertedStatus, errors, options);
1080
+ return verifyAnchorTrace(b, specVersionOk, errors, options);
1083
1081
  }
1084
1082
 
1085
1083
  /**
1086
- * Recompute an export's source verdict (evidence-export.md §5 step 4).
1087
- *
1088
- * The only export source is `sanning.anchor.trace/v3` (§2.2, §2.4). Any other
1089
- * inline source body is unrecognised *as a source*, even one this kernel
1090
- * verifies on its own, such as a `sanning.stamp.evidence/v2` pack: §2.4 makes
1091
- * its source verdict undetermined, so the export cannot reach `verified`. The
1092
- * source wrapper is still checked first, because a forged source is a failure
1093
- * whatever its body.
1084
+ * Recompute an export's source verdict (evidence-export.md §5 step 4). Only a
1085
+ * `sanning.anchor.trace/v3` source is recognised (§2.4); any other reads
1086
+ * undetermined, so the export cannot reach `verified`. The source wrapper is
1087
+ * checked first, because a forged source fails whatever its body.
1094
1088
  */
1095
1089
  async function verifyExportSource(
1096
1090
  source: unknown,
@@ -1100,9 +1094,8 @@ async function verifyExportSource(
1100
1094
  }
1101
1095
 
1102
1096
  /**
1103
- * Index a source bundle's checkpoints by the key its attestations use: the
1104
- * derived `checkpoint_ref`. Only a `sanning.anchor.trace/v3` source has
1105
- * checkpoints an attestation can resolve (evidence-export.md §2.4).
1097
+ * Index a source bundle's checkpoints by derived `checkpoint_ref`, the key
1098
+ * attestations use (evidence-export.md §2.4).
1106
1099
  */
1107
1100
  async function indexSourceCheckpoints(
1108
1101
  bundle: EvidenceBundle,
@@ -1129,20 +1122,10 @@ async function indexSourceCheckpoints(
1129
1122
  type AnyTraceCheckpoint = Partial<TraceCheckpoint> & { tx_id?: unknown };
1130
1123
 
1131
1124
  /**
1132
- * Resolve a checkpoint's join key and its witness reference.
1133
- *
1134
- * ── Why a retired spelling is a reject rather than a preference ──
1135
- *
1136
- * A checkpoint carrying a retired spelling beside the current one is two
1137
- * answers to one question inside one signature. A reader that silently prefers
1138
- * one gives a different answer from a reader that prefers the other, over the
1139
- * same signed bytes, and both look like they verified. So `tx_id` and
1140
- * `record_bytes` are rejected rather than resolved.
1141
- *
1142
- * ── Why the key is recomputed ──
1143
- *
1144
- * The key IS the bytes: relabelling a checkpoint and every event pointing at it
1145
- * fails here, because nothing is taken from the label.
1125
+ * Resolve a checkpoint's join key and witness reference. The key is recomputed
1126
+ * from the bytes, never taken from the label. A retired spelling (`tx_id`,
1127
+ * `record_bytes`) beside the current one is two answers inside one signature,
1128
+ * so it is rejected rather than resolved.
1146
1129
  */
1147
1130
  async function resolveCheckpointKey(
1148
1131
  cp: AnyTraceCheckpoint,
@@ -1154,8 +1137,7 @@ async function resolveCheckpointKey(
1154
1137
  errors: string[];
1155
1138
  }> {
1156
1139
  const errors: string[] = [];
1157
- // Every early return below leaves before `anchor_ref` is ever looked at, so
1158
- // nothing there is "malformed": it is unexamined, which is not the same claim.
1140
+ // Early returns never look at `anchor_ref`, so it is unexamined, not malformed.
1159
1141
  const unexamined = { key: null, anchorRef: null, anchorRefMalformed: false, errors };
1160
1142
 
1161
1143
  if (cp.tx_id !== undefined) {
@@ -1196,33 +1178,12 @@ async function resolveCheckpointKey(
1196
1178
  }
1197
1179
 
1198
1180
  /**
1199
- * Validate an optional `anchor_ref` (§5.1.1).
1181
+ * Validate an optional `anchor_ref` (§5.1.1): its shape, never its vocabulary.
1200
1182
  *
1201
- * ── An unrecognised `kind` is NOT a failure, and that is the point ──
1202
- *
1203
- * The offline verdict — signatures, payload binding, Merkle inclusion — is
1204
- * computed with no witness access at all. A reader that cannot resolve a
1205
- * witness has therefore learned NOTHING about whether the evidence was
1206
- * altered, and must not say otherwise. The shipped issuer did the opposite:
1207
- * an unrecognised signature type returned `false` rather than `null`, mapped
1208
- * to `tampered`, and was cached as permanent — a false accusation, produced
1209
- * by a coverage gap, for a company whose product is telling people whether
1210
- * records were altered.
1211
- *
1212
- * So this validates the SHAPE and never the vocabulary. A malformed
1213
- * `anchor_ref` is a defect in the bundle; an unknown `kind` is a defect in
1214
- * the reader, and only the first is the bundle's fault.
1215
- *
1216
- * ── Absent and malformed are not the same null (proof#120) ──
1217
- *
1218
- * Both leave no `AnchorRef` to re-fetch, and both used to be reported the same
1219
- * way downstream: an all-`unreachable` on-chain result, although no gateway was
1220
- * asked in either case. For the ABSENT case that is at least the note
1221
- * `evidence-bundle.md` §5.1.1 reserves for "a checkpoint with no `anchor_ref`".
1222
- * For the MALFORMED case it is a manufactured outage: the bundle named a
1223
- * witness, the name was unreadable, and the reader was told the public record
1224
- * could not be reached. So the two come back as different facts, and `malformed`
1225
- * carries the difference.
1183
+ * An unrecognised `kind` is not a failure: the offline verdict needs no witness,
1184
+ * so a reader that cannot resolve one has learned nothing about alteration and
1185
+ * must not accuse. A malformed `anchor_ref` is a defect in the bundle, and is
1186
+ * returned as `malformed` so it is never reported as an unreachable witness.
1226
1187
  */
1227
1188
  function parseAnchorRef(
1228
1189
  raw: unknown,
@@ -1262,7 +1223,6 @@ function parseAnchorRef(
1262
1223
  async function verifyAnchorTrace(
1263
1224
  b: EvidenceBundle,
1264
1225
  specVersionOk: boolean,
1265
- assertedStatus: string | null,
1266
1226
  errors: string[],
1267
1227
  options: VerifyEvidenceOptions,
1268
1228
  ): Promise<EvidenceBundleResult> {
@@ -1275,31 +1235,17 @@ async function verifyAnchorTrace(
1275
1235
  const gateways = options.gateways ?? [];
1276
1236
  const onChainChecked = gateways.length > 0;
1277
1237
 
1278
- // --- Step 3: per-checkpoint -------------------------------------------------
1279
- //
1280
- // ── Why the join key is resolved BEFORE anything else ──
1281
- //
1282
- // `byKey` is what binds an event to the root its inclusion proof is checked
1283
- // against. Get it wrong and an event proves inclusion in a tree it was never
1284
- // in — the one failure in this file that produces a WRONG VERIFIED, not a
1285
- // failed verify. Everything below is downstream of it.
1238
+ // Step 3: per checkpoint. `byKey` binds each event to the root its inclusion
1239
+ // is checked against; a wrong key here would yield a wrong `verified`.
1286
1240
  const byKey = new Map<string, AnyTraceCheckpoint>();
1287
1241
  const anchorByKey = new Map<string, AnchorRef | null>();
1288
1242
  const checkpointResults: CheckpointResult[] = [];
1289
- // proof#120. Kept beside the results rather than on them: "the bundle named
1290
- // a witness we could not read" is an input defect the checkpoint's own
1291
- // `errors` already states, not a new dimension of the published verdict, and
1292
- // `CheckpointResult` is a shape downstream surfaces render.
1243
+ // Kept beside the results: an unreadable witness name is an input defect the
1244
+ // checkpoint's `errors` already states, not a verdict dimension.
1293
1245
  const malformedAnchorRef = new Set<CheckpointResult>();
1294
1246
  for (const rawCp of body.checkpoints as unknown[]) {
1295
- // `checkpoints[]` is attacker-shaped, and a verifier must never raise on
1296
- // adversarial input: a null entry used to reach `cp.tx_id` and hand the
1297
- // caller a TypeError instead of a verdict, which an auditor cannot tell
1298
- // apart from a broken tool. Graded and reported as "not a checkpoint"
1299
- // rather than coerced into an empty one: probing the envelope layer with
1300
- // nothing asks it a question about an object that was never an envelope.
1301
- // Same treatment, same words, as the §5.3 path's `events[] entry is not an
1302
- // object`.
1247
+ // `checkpoints[]` is attacker-shaped; a verifier never raises on it. A
1248
+ // non-object entry is reported as not a checkpoint, never coerced.
1303
1249
  if (rawCp === null || typeof rawCp !== "object" || Array.isArray(rawCp)) {
1304
1250
  checkpointResults.push({
1305
1251
  checkpointRef: "",
@@ -1319,8 +1265,7 @@ async function verifyAnchorTrace(
1319
1265
  let merkleRootOk = false;
1320
1266
  let leafCountOk = false;
1321
1267
 
1322
- // Decode event_bytes first: in v2 the join key is DERIVED from it, so it
1323
- // is an input to identity, not just to the payload binding below.
1268
+ // The join key is derived from `event_bytes`, so decode them first.
1324
1269
  let eventBytes: Uint8Array | undefined;
1325
1270
  try {
1326
1271
  if (typeof cp.event_bytes === "string") eventBytes = hexToBytes(cp.event_bytes);
@@ -1330,15 +1275,9 @@ async function verifyAnchorTrace(
1330
1275
 
1331
1276
  const keyed = await resolveCheckpointKey(cp, eventBytes);
1332
1277
  cpErrors.push(...keyed.errors);
1333
- // A duplicate key is a HARD failure, not a note. `byKey` below keeps the
1334
- // FIRST occurrence, so a second checkpoint claiming the same key is
1335
- // verified against the first one's envelope and its own stated identity is
1336
- // never independently checked. Recording the finding and leaving `ok` true
1337
- // let the bundle roll up to `verified` while carrying a checkpoint nothing
1338
- // had verified: a false pass, which is what `evidence-bundle.md` §3
1339
- // principle 3 exists to prevent (the displayed verdict must be the
1340
- // recomputed one, and here the recomputation quietly dropped a finding it
1341
- // had made).
1278
+ // A duplicate key is a hard failure: `byKey` keeps the first, so a second
1279
+ // checkpoint's identity would otherwise never be checked and the bundle
1280
+ // could roll up `verified` over it.
1342
1281
  let duplicateKey = false;
1343
1282
  if (keyed.key !== null) {
1344
1283
  if (byKey.has(keyed.key)) {
@@ -1350,20 +1289,17 @@ async function verifyAnchorTrace(
1350
1289
  }
1351
1290
  }
1352
1291
 
1353
- // Verify the checkpoint envelope against its committed record (external
1354
- // commitment — the record is the payload these external envelopes bind to).
1292
+ // The record is the payload these external-commitment envelopes bind to.
1355
1293
  try {
1356
1294
  const res = await verifyEnvelope(
1357
1295
  cp.envelope as Envelope,
1358
1296
  eventBytes !== undefined ? { payloadBytes: eventBytes } : {},
1359
1297
  );
1360
- // A checkpoint must fully bind: signature + the committed record matching
1361
- // payload_hash. An undetermined binding (no record) is not enough to
1362
- // anchor the events' inclusion claims to a trustworthy root.
1298
+ // A checkpoint must fully bind: an undetermined binding cannot anchor the
1299
+ // events' inclusion claims to a trustworthy root.
1363
1300
  const hexError = envelopeHexError("checkpoint", cp.envelope);
1364
1301
  envelopeOk = res.ok && res.payloadHashOk === true && hexError === null;
1365
- // See the event loop below: a degenerate envelope is a malformed input,
1366
- // not a failed check (proof#76).
1302
+ // A degenerate envelope is malformed input, not a failed check.
1367
1303
  if (!isJsonObject(cp.envelope)) {
1368
1304
  cpErrors.push("checkpoint envelope is not a JSON object");
1369
1305
  } else if (hexError !== null) {
@@ -1372,16 +1308,12 @@ async function verifyAnchorTrace(
1372
1308
  cpErrors.push("checkpoint envelope signature failed");
1373
1309
  }
1374
1310
  if (res.payloadHashOk === false) cpErrors.push("checkpoint record does not bind to payload_hash");
1375
- // proof#95: a malformed skeleton field fails the envelope with a valid
1376
- // signature and binding; name it rather than fail with no reason.
1311
+ // Name a malformed skeleton field, which fails an otherwise valid envelope.
1377
1312
  for (const m of res.errors) if (m.startsWith("malformed ")) cpErrors.push(`checkpoint envelope ${m}`);
1378
1313
  const specFinding = specVersionFinding("checkpoint", cp.envelope, res);
1379
1314
  if (specFinding !== null) cpErrors.push(specFinding);
1380
- // WHY the binding is undetermined decides which sentence is true.
1381
- // event_bytes really absent is one thing; event_bytes present and
1382
- // unbindable (a degenerate envelope, unreadable hex) is another, and
1383
- // naming the wrong cause sends a reader to look for a field that is
1384
- // sitting right there (proof#76).
1315
+ // Name why the binding is undetermined: absent bytes and unbindable bytes
1316
+ // are different causes.
1385
1317
  if (res.payloadHashOk === null) {
1386
1318
  cpErrors.push(
1387
1319
  eventBytes === undefined
@@ -1393,8 +1325,7 @@ async function verifyAnchorTrace(
1393
1325
  cpErrors.push(`checkpoint envelope verify error${thrownDetail(e)}`);
1394
1326
  }
1395
1327
 
1396
- // The committed record's merkle_root must equal the checkpoint's claimed
1397
- // merkle_root (recompute, don't trust the bundle's top-level claim).
1328
+ // Recompute: the committed record's merkle_root must equal the claimed one.
1398
1329
  if (eventBytes !== undefined) {
1399
1330
  try {
1400
1331
  const record = jcsParse(eventBytes) as {
@@ -1408,12 +1339,9 @@ async function verifyAnchorTrace(
1408
1339
  `committed=${jsonStr(committedRoot)}`,
1409
1340
  );
1410
1341
  }
1411
- // §5.1, A38 (proof#197), decided by Phil 2026-10-06: fail closed. The
1412
- // count the record signs is what every inclusion bound to this
1413
- // checkpoint is held to, so a record that signs no integer count fails
1414
- // the checkpoint, as a record that signs no merkle_root does. Read by
1415
- // value through the one reader the inclusion compare and the
1416
- // completeness notes share, so `3.0` and `3e0` are 3.
1342
+ // Fail closed (§5.1): every inclusion is held to the count the record
1343
+ // signs, so a record that signs no integer count fails the checkpoint.
1344
+ // Read by value through the shared reader, so `3.0` and `3e0` are 3.
1417
1345
  leafCountOk = signedLeafCount(cp as { event_bytes?: unknown }) !== null;
1418
1346
  if (!leafCountOk) cpErrors.push("checkpoint record leaf_count is not an integer");
1419
1347
  } catch (e) {
@@ -1428,26 +1356,32 @@ async function verifyAnchorTrace(
1428
1356
  merkleRootOk,
1429
1357
  onChainOk: null, // filled in step 5 if a gateway re-fetch was requested
1430
1358
  onChain: null, // per-gateway (§4.2), filled in step 5
1431
- // `keyed.errors` is load-bearing here, not decorative. A structurally
1432
- // invalid anchor_ref rides INSIDE the signature, so it is a defect we
1433
- // can see rather than a coverage gap we cannot — and this kernel's
1434
- // standing rule is that malformed is a failure while absent is
1435
- // undetermined (the same rule disclosed `content` follows). Without
1436
- // this term the vector `trace-v2-malformed-anchor-ref` verified GREEN.
1359
+ // A malformed `anchor_ref` is inside the signature: a visible defect,
1360
+ // which fails, while an absent one is undetermined.
1437
1361
  ok: envelopeOk && merkleRootOk && leafCountOk && keyed.errors.length === 0 && !duplicateKey,
1438
1362
  errors: cpErrors,
1363
+ anchorRefMalformed:
1364
+ keyed.anchorRefMalformed && envelopeOk && merkleRootOk && leafCountOk && !duplicateKey,
1439
1365
  });
1440
1366
  if (keyed.anchorRefMalformed) {
1441
1367
  malformedAnchorRef.add(checkpointResults[checkpointResults.length - 1]);
1442
1368
  }
1443
1369
  }
1444
1370
 
1445
- // --- Step 4: per-event ------------------------------------------------------
1371
+ // Every checkpoint ref the pack holds, derived or stated, compared as hex is. A ref
1372
+ // outside it names a checkpoint the pack does not hold, which is a stamp missing, never a
1373
+ // stamp proven wrong (section 5.1, as section 5.3.3 rule 1 reads a stamp).
1374
+ const heldRefs = new Set<string>([...byKey.keys()].map((k) => k.toLowerCase()));
1375
+ for (const raw of body.checkpoints as unknown[]) {
1376
+ const ref = isJsonObject(raw) ? (raw as { checkpoint_ref?: unknown }).checkpoint_ref : undefined;
1377
+ if (typeof ref === "string") heldRefs.add(ref.toLowerCase());
1378
+ }
1379
+
1380
+ // Step 4: per event.
1446
1381
  const eventResults: EventResult[] = [];
1382
+ const agentItems: (AgentHeldRecord | null)[] = [];
1447
1383
  for (const rawEv of body.events as unknown[]) {
1448
- // Same rule as `checkpoints[]` above, and the same words the §5.3 path
1449
- // already uses: a null or non-object entry is graded as not an event,
1450
- // never raised on.
1384
+ // A non-object entry is graded as not an event, never raised on.
1451
1385
  if (rawEv === null || typeof rawEv !== "object" || Array.isArray(rawEv)) {
1452
1386
  eventResults.push({
1453
1387
  eventId: "<unknown>",
@@ -1458,35 +1392,24 @@ async function verifyAnchorTrace(
1458
1392
  contentOk: null,
1459
1393
  ok: false,
1460
1394
  errors: ["events[] entry is not an object"],
1395
+ agent: null,
1461
1396
  });
1397
+ agentItems.push(null);
1462
1398
  continue;
1463
1399
  }
1464
1400
  const ev = rawEv as TraceEvent;
1465
1401
  const evErrors: string[] = [];
1466
1402
  const eventId = getEventId(ev.envelope);
1467
1403
  let envelopeOk = false;
1404
+ let envelopeFaults: EnvelopeFault[] = [];
1468
1405
  let payloadBindingOk: boolean | null = null;
1469
1406
 
1470
- // A retired field name under this major is a reject, never an ignored
1471
- // unknown field: ignored, it reads as a withheld record (W-3).
1407
+ // A retired field name is a reject: ignored, it would read as withheld.
1472
1408
  const legacyError = legacyEventBytesError(ev, "event", bodyType);
1473
1409
  if (legacyError !== null) evErrors.push(legacyError);
1474
1410
 
1475
- // MALFORMED IS A FAILURE; ABSENT IS UNDETERMINED (proof#85).
1476
- //
1477
- // `event_bytes` that will not decode leaves `eventBytes` undefined, and an
1478
- // undefined `eventBytes` is exactly how a WITHHELD record is spelled one
1479
- // line down. So an event carrying garbage in place of its record used to
1480
- // reach the same tri-state as an event carrying nothing: `payloadHashOk`
1481
- // null, `ok` true, a leading tick on the line, and a `verified` rollup over
1482
- // a record nobody could read. The only thing wrong with the pack was the
1483
- // bytes, and it was the one thing the verdict did not say.
1484
- //
1485
- // A record the producer chose not to disclose is the customer exercising
1486
- // minimum disclosure. A record that is present and unreadable is a defect
1487
- // in the artifact, and this kernel's standing rule (the same one
1488
- // `checkpoints[]` already applies through `keyed.errors`) is that a defect
1489
- // we can see fails.
1411
+ // Malformed is a failure; absent is undetermined. Undecodable `event_bytes`
1412
+ // would otherwise look exactly like a withheld record and verify.
1490
1413
  let eventBytes: Uint8Array | undefined;
1491
1414
  let eventBytesMalformed = false;
1492
1415
  try {
@@ -1495,10 +1418,10 @@ async function verifyAnchorTrace(
1495
1418
  eventBytesMalformed = true;
1496
1419
  evErrors.push(`event event_bytes is not hex${thrownDetail(e)}`);
1497
1420
  }
1498
- const duplicate = recordDuplicateKey(eventBytes);
1499
- if (duplicate !== undefined) {
1421
+ const notJson = recordNotJson(eventBytes);
1422
+ if (notJson !== undefined) {
1500
1423
  eventBytesMalformed = true;
1501
- evErrors.push(`event record is not JSON: ${duplicate}`);
1424
+ evErrors.push(notJson);
1502
1425
  }
1503
1426
  try {
1504
1427
  const res = await verifyEnvelope(
@@ -1506,16 +1429,12 @@ async function verifyAnchorTrace(
1506
1429
  eventBytes !== undefined ? { payloadBytes: eventBytes } : {},
1507
1430
  );
1508
1431
  const hexError = envelopeHexError("event", ev.envelope);
1509
- // ok tolerates an undetermined binding (withheld record)
1432
+ // Tolerates an undetermined binding (withheld record).
1510
1433
  envelopeOk = res.ok && hexError === null;
1434
+ envelopeFaults = eventEnvelopeFaults(res.faults, hexError);
1511
1435
  payloadBindingOk = res.payloadHashOk;
1512
- // P-10: report what could not be determined, never a fault that was never
1513
- // observed. There is no envelope here, so no signature was checked and no
1514
- // binding was attempted. "signature failed" named a check that never ran,
1515
- // and it was the only thing said about an event whose undetermined
1516
- // binding then rendered as a WITHHELD record, a claim about the
1517
- // producer's disclosure choice out of an input that says nothing about
1518
- // the producer at all (proof#76).
1436
+ // No envelope, so no signature was checked: report it as malformed, not as
1437
+ // a failed signature or a withheld record.
1519
1438
  if (!isJsonObject(ev.envelope)) {
1520
1439
  evErrors.push("event envelope is not a JSON object");
1521
1440
  } else if (hexError !== null) {
@@ -1524,27 +1443,27 @@ async function verifyAnchorTrace(
1524
1443
  evErrors.push("event envelope signature failed");
1525
1444
  }
1526
1445
  if (res.payloadHashOk === false) evErrors.push("event record does not bind to payload_hash");
1527
- // A malformed skeleton field fails the envelope with a valid signature
1528
- // and binding (proof#95), so without this the event fails with no reason.
1446
+ // Name a malformed skeleton field, which fails an otherwise valid envelope.
1529
1447
  for (const m of res.errors) if (m.startsWith("malformed ")) evErrors.push(`event envelope ${m}`);
1448
+ if (res.errors.some((m) => m.startsWith("missing payload_hash"))) evErrors.push("event envelope missing payload_hash");
1530
1449
  const specFinding = specVersionFinding("event", ev.envelope, res);
1531
1450
  if (specFinding !== null) evErrors.push(specFinding);
1532
1451
  } catch (e) {
1533
1452
  evErrors.push(`event envelope verify error${thrownDetail(e)}`);
1534
1453
  }
1535
1454
 
1536
- // Bind the event to exactly one checkpoint in the bundle, then verify
1537
- // RFC 9162 inclusion against THAT checkpoint's merkle_root.
1455
+ // Bind the event to exactly one checkpoint, then verify RFC 9162 inclusion
1456
+ // against that checkpoint's merkle_root.
1538
1457
  const incl = ev.inclusion as (TraceInclusion & { checkpoint_tx_id?: unknown }) | undefined;
1539
- // An inclusion carrying the retired `checkpoint_tx_id` spelling is a mixed
1540
- // bundle, and resolving it anyway is how an event silently binds to a
1541
- // checkpoint nobody meant.
1458
+ // The retired `checkpoint_tx_id` spelling marks a mixed bundle; resolving
1459
+ // it could bind the event to an unintended checkpoint.
1542
1460
  const keyField = "checkpoint_ref";
1543
1461
  const wrongField = "checkpoint_tx_id";
1544
1462
  const inclAsRecord = incl as unknown as Record<string, unknown> | undefined;
1545
1463
  const rawKey = inclAsRecord ? inclAsRecord[keyField] : undefined;
1546
1464
  const strayKey = inclAsRecord ? inclAsRecord[wrongField] : undefined;
1547
1465
  let cp: AnyTraceCheckpoint | undefined;
1466
+ let stampAbsent = false;
1548
1467
  if (!incl || typeof incl !== "object") {
1549
1468
  evErrors.push("event missing inclusion proof");
1550
1469
  } else if (strayKey !== undefined) {
@@ -1555,7 +1474,14 @@ async function verifyAnchorTrace(
1555
1474
  evErrors.push(`event inclusion missing ${keyField}`);
1556
1475
  } else {
1557
1476
  cp = byKey.get(rawKey);
1558
- if (cp === undefined) {
1477
+ if (cp === undefined && !heldRefs.has(rawKey.toLowerCase())) {
1478
+ // The pack does not hold the checkpoint the record names: `witnessed` is not
1479
+ // proven (`missing`).
1480
+ stampAbsent = true;
1481
+ evErrors.push(
1482
+ `event inclusion.${keyField} ${JSON.stringify(rawKey)} names a checkpoint this pack does not hold`,
1483
+ );
1484
+ } else if (cp === undefined) {
1559
1485
  evErrors.push(
1560
1486
  `event inclusion.${keyField} ${JSON.stringify(rawKey)} resolves to no checkpoint`,
1561
1487
  );
@@ -1564,22 +1490,12 @@ async function verifyAnchorTrace(
1564
1490
  const checkpointBound = cp !== undefined;
1565
1491
 
1566
1492
  let inclusionOk = false;
1567
- // §5.1, A38 (proof#197): the tree walked must be the tree the checkpoint
1568
- // record signed, the same rule and the same sentence as the stamp path's
1569
- // rule 3. RFC 9162's walk reads one audit path as valid at more than one
1570
- // (leaf_index, leaf_count), so without this a real proof at (2, 3) also
1571
- // verified as (1, 2) or (4, 5). A signed count that cannot be read is not
1572
- // compared here: it has already failed its checkpoint (above), so the
1573
- // bundle cannot verify. One reader of the signed count, shared with the
1574
- // completeness notes, so the verdict and the grade never read two
1575
- // different numbers.
1576
- //
1577
- // Every number here is read BY VALUE (`Number.isSafeInteger`), the model
1578
- // JCS signs, and the Python kernel reads the same way (`_safe_integer`):
1579
- // JCS writes `3.0` as `3`, so a stated `3.0` is the integer 3 under the
1580
- // same wrapper signature. A value that is not a safe integer is malformed,
1581
- // with one sentence that prints no value, because the two kernels cannot
1582
- // print `2.5`, `1e400` or `9007199254740993` the same way.
1493
+ // The tree walked must be the tree the checkpoint record signed (§5.1): an
1494
+ // RFC 9162 audit path is valid at more than one (leaf_index, leaf_count),
1495
+ // so a proof at (2, 3) would also verify as (1, 2). An unreadable signed
1496
+ // count already failed its checkpoint. Numbers are read by value, as JCS
1497
+ // signs them; a non-safe-integer is malformed, in a sentence that prints no
1498
+ // value because the kernels cannot print it alike.
1583
1499
  const signedCount = cp ? signedLeafCount(cp as { event_bytes?: unknown }) : null;
1584
1500
  if (incl && checkpointBound && cp) {
1585
1501
  const statedCount = safeInteger(incl.leaf_count);
@@ -1600,19 +1516,14 @@ async function verifyAnchorTrace(
1600
1516
  statedIndex,
1601
1517
  statedCount,
1602
1518
  (incl.audit_path ?? []).map((h) => hexToBytes(h)),
1603
- // No `?? ""` fallback. A checkpoint that claims NO root must not be
1604
- // handed an invented empty one: the Python kernel refuses the same
1605
- // value and reports the decode failure, so substituting "" here had
1606
- // the two kernels say different things about one bundle (TS: "the
1607
- // proof did not reconstruct the checkpoint root", which is not what
1608
- // happened: there was no claimed root to reconstruct).
1519
+ // No `?? ""` fallback: a checkpoint claiming no root must not be
1520
+ // handed an invented one.
1609
1521
  hexToBytes(cp.merkle_root as string),
1610
1522
  );
1611
1523
  if (!inclusionOk) evErrors.push("RFC 9162 inclusion proof did not reconstruct the checkpoint root");
1612
1524
 
1613
- // Defense in depth: the claimed leaf_hash MUST be the hash of the
1614
- // event's own signed envelope bytes (profile §6) — otherwise a valid
1615
- // inclusion proof for an UNRELATED leaf could be smuggled in.
1525
+ // The claimed leaf_hash must hash the event's own signed envelope
1526
+ // (profile §6), or a proof for an unrelated leaf could be smuggled in.
1616
1527
  const expectedLeaf = await leafHash(utf8(jcs(ev.envelope)));
1617
1528
  const expectedLeafHex = toHex(expectedLeaf);
1618
1529
  if (expectedLeafHex !== (incl.leaf_hash ?? "").toLowerCase()) {
@@ -1627,10 +1538,12 @@ async function verifyAnchorTrace(
1627
1538
  }
1628
1539
  }
1629
1540
 
1630
- // Content (raw-log) binding — shared with every other event-carrying body.
1631
1541
  const contentRes = await verifyDisclosedContent(ev, eventId, eventBytes, options.content);
1632
1542
  const contentOk = contentRes.contentOk;
1633
1543
  evErrors.push(...contentRes.errors);
1544
+ agentItems.push(
1545
+ agentItem(ev, eventBytes, envelopeOk && payloadBindingOk === true && !eventBytesMalformed),
1546
+ );
1634
1547
 
1635
1548
  eventResults.push({
1636
1549
  eventId,
@@ -1639,13 +1552,9 @@ async function verifyAnchorTrace(
1639
1552
  inclusionOk,
1640
1553
  checkpointBound,
1641
1554
  contentOk,
1642
- // An event is "ok" when its envelope is authentic (signature + binding
1643
- // not-failed), its inclusion proof reconstructs the root, it binds to a
1644
- // present checkpoint, and its disclosed content did not MISMATCH. A
1645
- // withheld record or undisclosed content (binding/content null) does not
1646
- // fail it.
1647
- // `!eventBytesMalformed`: present-and-unreadable record bytes are a
1648
- // defect in the artifact, never a disclosure choice (proof#85).
1555
+ // Ok when the envelope is authentic, inclusion reconstructs the root, the
1556
+ // checkpoint is present, and disclosed content did not mismatch. Withheld
1557
+ // or undisclosed content does not fail it; unreadable record bytes do.
1649
1558
  ok:
1650
1559
  envelopeOk &&
1651
1560
  inclusionOk &&
@@ -1654,16 +1563,24 @@ async function verifyAnchorTrace(
1654
1563
  legacyError === null &&
1655
1564
  !eventBytesMalformed,
1656
1565
  errors: evErrors,
1566
+ agent: null,
1567
+ envelopeFault: envelopeFaults[0] ?? null,
1568
+ envelopeFaults,
1569
+ recordNotJson: notJson !== undefined && envelopeOk && payloadBindingOk === true,
1570
+ contentUnsigned: contentOk === false && contentRes.unsigned,
1571
+ stampAbsent,
1572
+ ownFormFailed: legacyError !== null || eventBytesMalformed,
1657
1573
  });
1658
1574
  }
1659
1575
 
1660
- // --- Step 5: optional on-chain re-fetch ------------------------------------
1576
+ await attachAgentReadings(eventResults, agentItems);
1577
+
1578
+ // Step 5: optional on-chain re-fetch.
1661
1579
  const fetchImpl = options.fetchImpl ?? (globalThis.fetch as typeof fetch | undefined);
1662
1580
  let gatewayUnavailable = false;
1663
1581
  let uncheckedKind = false;
1664
1582
  let uncheckedId = false;
1665
- // Simulated witnesses against witnesses of any other kind, counted over the
1666
- // checkpoints that name one, as `refetchStamps` counts stamps.
1583
+ // Simulated witnesses against any other kind, as `refetchStamps` counts.
1667
1584
  let simulated = 0;
1668
1585
  let onChainKinds = 0;
1669
1586
  if (onChainChecked) {
@@ -1674,21 +1591,11 @@ async function verifyAnchorTrace(
1674
1591
  for (const cpr of checkpointResults) {
1675
1592
  const cp = byKey.get(cpr.checkpointRef);
1676
1593
  if (!cp) continue;
1677
- // A checkpoint with no anchor_ref has NOTHING to re-fetch. Under DL-52
1678
- // that is the normal state inside a stamp interval — placed, readable,
1679
- // not yet witnessed — so it reports as unreachable (which downgrades
1680
- // the run to partial) and NEVER as a mismatch. Treating "we never
1681
- // anchored it yet" as "the chain disagrees" would be the same false
1682
- // accusation this whole change is removing.
1683
- // proof#120. An `anchor_ref` that failed structural validation is not
1684
- // an absent one. Nothing was fetched, so the witness dimension is NOT
1685
- // CHECKED — `onChain: null`, exactly as when the caller supplied no
1686
- // adapter — and it must not raise `gatewayUnavailable`, which is what
1687
- // puts "could not be re-fetched from the supplied gateways" on the
1688
- // bundle. Reporting an outage nobody observed is a claim about the
1689
- // public record made out of a defect in the artifact, and verify-page
1690
- // renders it word for word. The run still fails: the structural error
1691
- // is already in `cpr.errors` and already in `ok` above.
1594
+ // No `anchor_ref`: nothing to re-fetch. Absent (placed, not yet
1595
+ // witnessed) reports as unreachable, downgrading the run to partial and
1596
+ // never to a mismatch. Malformed reports as not checked (`onChain:
1597
+ // null`) and must not raise `gatewayUnavailable`: no outage was
1598
+ // observed, and the structural error already fails the run.
1692
1599
  if (cpr.anchorRef === null && malformedAnchorRef.has(cpr)) {
1693
1600
  cpr.onChain = null;
1694
1601
  cpr.onChainOk = null;
@@ -1714,54 +1621,46 @@ async function verifyAnchorTrace(
1714
1621
  if (outcome.unchecked === "kind") uncheckedKind = true;
1715
1622
  if (outcome.unchecked === "id") uncheckedId = true;
1716
1623
  if (outcome.error) cpr.errors.push(outcome.error);
1717
- // Fold the on-chain dimension in: a MISMATCH (false) fails the
1718
- // checkpoint — the proof was not anchored as claimed. An UNREACHABLE
1719
- // gateway (null) leaves the offline-sound checkpoint ok; the run
1720
- // downgrades to "partial" (exit 3) via gatewayUnavailable, not a hard
1721
- // failure. A match (true) keeps it ok.
1722
- // AND into the verdict already computed, never rebuild it from a
1723
- // hand-listed set of dimensions. Rebuilding silently dropped every
1724
- // term not in that list: the duplicate-key gate (proof#28) and the
1725
- // `keyed.errors` term with it, so supplying a gateway turned a hard
1726
- // reject back into `verified` and an UNREACHABLE gateway turned it
1727
- // into `partial` (exit 3), which reads as a network problem an
1728
- // operator retries. Neither is conditioned on a gateway by the spec:
1729
- // `evidence-bundle.md` says two checkpoints carrying one key is a
1730
- // hard reject, full stop. Written this way, a term added at
1731
- // construction survives here by construction.
1624
+ // A mismatch fails the checkpoint; unreachable leaves it ok and
1625
+ // downgrades the run to partial. AND into the verdict already computed,
1626
+ // never rebuild it, so every construction-time term (the duplicate-key
1627
+ // gate included) survives.
1732
1628
  cpr.ok = cpr.ok && cpr.onChainOk !== false;
1733
1629
  }
1734
1630
  }
1735
1631
  }
1736
1632
 
1737
- // --- Rollup ----------------------------------------------------------------
1633
+ // Rollup.
1738
1634
  const allCheckpointsOk = checkpointResults.length > 0 && checkpointResults.every((c) => c.ok);
1739
1635
  const allEventsOk = eventResults.every((e) => e.ok);
1740
1636
  const anyHardFailure =
1741
- checkpointResults.some((c) => !c.ok) || eventResults.some((e) => !e.ok);
1637
+ checkpointResults.some((c) => !c.ok) || eventResults.some((e) => !e.ok && !unproven(e));
1742
1638
  const anyWithheldRecord = eventResults.some((e) => e.payloadBindingOk === null);
1743
-
1639
+ const anyStampAbsent = eventResults.some((e) => e.stampAbsent === true);
1640
+
1641
+ // A lookup that did not settle is the witness line's to say, never the status's: the
1642
+ // witness is proven offline by the checkpoint and its inclusion proof, and only a lookup
1643
+ // that finds other bytes (a hard failure above) moves anything.
1644
+ pushRefetchGapNotes(errors, "checkpoints", {
1645
+ unavailable: gatewayUnavailable,
1646
+ kind: uncheckedKind,
1647
+ id: uncheckedId,
1648
+ });
1649
+ // A checkpoint nobody has witnessed yet (no anchor_ref) is placed, not witnessed: the
1650
+ // `witnessed` claim is not proven (evidence-bundle.md section 3), as for a stamp.
1651
+ const anyUnwitnessed = checkpointResults.some((c) => c.anchorRef === null);
1744
1652
  let status: EvidenceStatus;
1745
1653
  if (anyHardFailure) {
1746
1654
  status = "failed";
1747
- } else if (gatewayUnavailable || uncheckedKind || uncheckedId) {
1748
- // Every offline check passed and nothing MISMATCHED, but a requested
1749
- // on-chain confirmation could not be made — the bundle is offline-sound,
1750
- // its anchored state unconfirmed. "partial" + a note the CLI maps to exit 3.
1751
- status = "partial";
1752
- pushRefetchGapNotes(errors, "checkpoints", {
1753
- unavailable: gatewayUnavailable,
1754
- kind: uncheckedKind,
1755
- id: uncheckedId,
1756
- });
1655
+ } else if (anyUnwitnessed || anyStampAbsent || eventResults.some(unproven)) {
1656
+ status = "incomplete";
1757
1657
  } else if (anyWithheldRecord) {
1758
- // Everything that COULD be checked passed; some record bytes were withheld
1759
- // so their semantic binding is undetermined (not a failure) — "partial".
1760
- status = "partial";
1658
+ // Some record bytes were withheld, so their binding is not checked.
1659
+ status = "incomplete";
1761
1660
  } else if (allCheckpointsOk && allEventsOk) {
1762
1661
  status = "verified";
1763
1662
  } else {
1764
- status = "partial";
1663
+ status = "incomplete";
1765
1664
  }
1766
1665
 
1767
1666
  return {
@@ -1770,63 +1669,22 @@ async function verifyAnchorTrace(
1770
1669
  bodyHashOk: true,
1771
1670
  signatureOk: true,
1772
1671
  bodyType,
1773
- // A run whose only witnessed checkpoints are simulated looked nothing up
1774
- // on a chain, so it is not counted as checked on one (the stamp rule in
1775
- // `refetchStamps`). The checkpoints stay not checked and the run partial.
1672
+ // Simulated-only witnesses looked nothing up on a chain, so the run is not
1673
+ // counted as checked on one.
1776
1674
  onChainChecked: onChainChecked && !(simulated > 0 && onChainKinds === 0),
1777
1675
  checkpoints: checkpointResults,
1778
1676
  events: eventResults,
1779
- assertedStatus,
1780
1677
  errors,
1781
1678
  };
1782
1679
  }
1783
1680
 
1784
- // ===========================================================================
1785
- // `sanning.stamp.trace/v1` — the Sanning-signed interval stamp (§5.2, DL-58)
1786
- //
1787
- // ── What this can prove, and what it deliberately does not claim ──
1788
- //
1789
- // A stamp is not a producer artifact. Under DL-52 the customer signs an
1790
- // envelope and POSTs it; the commitment is witnessed later, on an interval, by
1791
- // a stamp the customer never sees and holds no key for. So there is no
1792
- // producer signature inside this body to check, and — this is the part worth
1793
- // being precise about — there is no SECOND Sanning signature either. The
1794
- // control plane stores the stamp's canonical bytes and hands them to a witness
1795
- // as a signed chain data item; the signature lives on the chain, not in the
1796
- // `stamps` table and not in this body.
1797
- //
1798
- // What "Sanning signed this" means for a stamp bundle is therefore exactly the
1799
- // WRAPPER signature, verified generically before this function is reached. And
1800
- // what makes the stamp evidence rather than an assertion is the witness, which
1801
- // is on a chain and not checkable offline. Hence the rollup below: a stamp we
1802
- // can read but nobody has witnessed holds the bundle at `partial`.
1803
- //
1804
- // So the checkable surface here is the stamp's INTERNAL consistency against
1805
- // the one authority in the bundle — the witnessed bytes:
1806
- //
1807
- // 1. `stamp_ref` is SHA-256 of the DECODED bytes (derived, not asserted).
1808
- // 2. Those bytes parse and re-canonicalize to themselves under JCS.
1809
- // 3. Their `stamp_version` is the one this body_type pins.
1810
- // 4. Every sub-tree range accounts for exactly one leaf of the stamp.
1811
- //
1812
- // (4) is the one that carries customer weight. R-8's whole per-agent story is
1813
- // "prove this agent was under evidence over window W", answered by a
1814
- // contiguous `first_leaf`/`leaf_count` slice of the org tree. Ranges that do
1815
- // not tile `[0, leaf_count)` describe a partition that does not exist, so a
1816
- // slice claim resting on them is unfounded — and unlike a bad signature,
1817
- // nothing else in the stack would ever notice.
1818
- // ===========================================================================
1819
-
1820
- // A JSON object, as opposed to null, an array, a string or a number. Used at
1821
- // every point where a nested field MUST be an object before any check of it
1822
- // means anything: probing a verifier with a non-object asks a question about
1823
- // something that was never the thing, and the answer must say so rather than
1824
- // name a check that never ran (proof#76).
1681
+ // A JSON object, as opposed to null, an array, a string or a number. A nested
1682
+ // field must be one before any check of it means anything.
1825
1683
  function isJsonObject(v: unknown): v is Record<string, unknown> {
1826
1684
  return typeof v === "object" && v !== null && !Array.isArray(v);
1827
1685
  }
1828
1686
 
1829
- /** Lowercase hex, even length, non-empty. The family's serialization rule. */
1687
+ /** Lowercase hex, even length, non-empty. */
1830
1688
  function isLowerHex(s: unknown, nibbles?: number): s is string {
1831
1689
  if (typeof s !== "string" || s.length === 0 || s.length % 2 !== 0) return false;
1832
1690
  if (nibbles !== undefined && s.length !== nibbles) return false;
@@ -1834,61 +1692,36 @@ function isLowerHex(s: unknown, nibbles?: number): s is string {
1834
1692
  }
1835
1693
 
1836
1694
  /**
1837
- * A JSON number read BY VALUE as a non-negative safe integer, or null.
1838
- * `safeInteger`, the reader every leaf count and leaf index in this kernel
1839
- * goes through (A38), mirrored by `_non_neg_integer` in
1840
- * `src/sanning_proof/evidence.py`. `Number.isInteger` alone accepted `1e+21`,
1841
- * JCS's own spelling of a count past 2**53 - 1, which Python refused.
1695
+ * A JSON number read by value as a non-negative safe integer, or null. Matches
1696
+ * Python's `_non_neg_integer`; `Number.isInteger` alone would accept `1e+21`.
1842
1697
  */
1843
1698
  function nonNegInteger(n: unknown): number | null {
1844
1699
  const v = safeInteger(n);
1845
1700
  return v !== null && v >= 0 ? v : null;
1846
1701
  }
1847
1702
 
1848
- /**
1849
- * Parse and check one stamp's witnessed bytes.
1850
- *
1851
- * Errors accumulate rather than short-circuit wherever a later check is still
1852
- * meaningful, so a bundle with two independent defects reports two — a
1853
- * verifier that stops at the first one turns a second fix into a second
1854
- * round-trip.
1855
- */
1856
- // ===========================================================================
1857
- // Disclosed-content binding, shared by every body that carries events.
1858
- //
1859
- // Extracted for `sanning.stamp.evidence/v2` (§5.3) rather than copied. The
1860
- // rawLog -> content_hash link is the one step the chain
1861
- // (content_hash -> record -> payload_hash -> signature -> leaf -> root) does
1862
- // not otherwise cover, and a second copy of it is a second place for the
1863
- // never-fail-on-absent discipline to drift. The rules are unchanged from the
1864
- // anchor-trace path this came from: undetermined (null) when nothing is
1865
- // disclosed or nothing is committed; false ONLY on a real mismatch or an
1866
- // in-body vs side-input disagreement.
1867
- // ===========================================================================
1703
+ // Disclosed-content binding, shared by every body that carries events. The
1704
+ // raw log to `content_hash` link is the one step the signature chain does not
1705
+ // cover. Undetermined (null) when nothing is disclosed or committed; false only
1706
+ // on a real mismatch or an in-body vs side-input disagreement.
1868
1707
  async function verifyDisclosedContent(
1869
1708
  ev: { envelope?: unknown; content?: unknown },
1870
1709
  eventId: string,
1871
1710
  eventBytes: Uint8Array | undefined,
1872
1711
  sideContent: VerifyEvidenceOptions["content"],
1873
- ): Promise<{ contentOk: boolean | null; errors: string[] }> {
1712
+ ): Promise<{ contentOk: boolean | null; unsigned: boolean; errors: string[] }> {
1874
1713
  const evErrors: string[] = [];
1875
1714
  let contentOk: boolean | null = null;
1876
-
1877
- // WHICH hash in the record is decided by the record's STRUCTURE
1878
- // (evidence-bundle §5.1 v1.19, A20). A record `envelope-spec.md` §4.1
1879
- // governs (`isHandoffNamespace`) commits to its message through
1880
- // `event.message_hash`, SHA-256 of `JCS(message object)`, and has no
1881
- // `content_hash`, so until A20 its disclosure bound to nothing and an honest
1882
- // hand-off pack could never grade `verified`. For such a record the committed
1883
- // hash is `event.message_hash` and nothing else: not a `content_hash` it also
1884
- // carries, and not the envelope's. It binds only when the record is a
1885
- // CONFORMANT hand-off record under the key that signed it
1886
- // (`isConformantHandoffRecord`) and the value is 64 characters of lowercase
1887
- // hex. Anything short of that commits nothing this reader will bind to, so
1888
- // the reading is undetermined and never true: a record dressed as a hand-off
1889
- // record gains no binding from the dress, and a record outside §4.1 keeps its
1890
- // `content_hash` exactly as before, whatever `message_hash` field it writes.
1891
- // The Python twin is `evidence._evaluate_content`.
1715
+ // Whether a `false` rests on side bytes nobody signed: the side input checked alone, or
1716
+ // side bytes that differ from in-body bytes that do not fail on their own.
1717
+ let unsigned = false;
1718
+
1719
+ // Which hash binds depends on the record's structure (§5.1). A record §4.1
1720
+ // governs commits through `event.message_hash` (SHA-256 of the message's
1721
+ // JCS), and binds only when it is a conformant hand-off record and the value
1722
+ // is 64 lowercase hex characters; otherwise the reading is undetermined, never
1723
+ // true. Any other record binds through `content_hash`. Python's twin is
1724
+ // `_evaluate_content`.
1892
1725
  const committed: string[] = [];
1893
1726
  let recordContentHash: string | undefined;
1894
1727
  let governed = false;
@@ -1922,8 +1755,8 @@ async function verifyDisclosedContent(
1922
1755
  }
1923
1756
  }
1924
1757
  } catch {
1925
- // record not JSON — the payload binding already reflects that; content
1926
- // stays undetermined rather than throwing.
1758
+ // Record not JSON: the payload binding reflects that; content stays
1759
+ // undetermined.
1927
1760
  }
1928
1761
  }
1929
1762
  if (governed) {
@@ -1931,9 +1764,8 @@ async function verifyDisclosedContent(
1931
1764
  } else if (recordContentHash !== undefined) {
1932
1765
  committed.push(recordContentHash);
1933
1766
  } else if (ev.envelope && typeof ev.envelope === "object") {
1934
- // Promoted fallback. Guarded: contentHashes() dereferences env.payload,
1935
- // which throws on a null/non-object envelope — a verifier must never raise
1936
- // on adversarial input.
1767
+ // Promoted fallback, guarded: `contentHashes` would throw on a non-object
1768
+ // envelope.
1937
1769
  for (const c of contentHashes(ev.envelope as Envelope)) committed.push(c.hash.toLowerCase());
1938
1770
  }
1939
1771
 
@@ -1946,6 +1778,8 @@ async function verifyDisclosedContent(
1946
1778
  evErrors.push(
1947
1779
  "disclosed content disagreement: in-body events[].content and the supplied content differ",
1948
1780
  );
1781
+ // Unsigned unless the in-body bytes, which the wrapper signs, fail on their own.
1782
+ unsigned = committed.length === 0 || committed.includes(await sha256Hex(inBody));
1949
1783
  } else {
1950
1784
  const disclosed = inBody ?? side;
1951
1785
  if (disclosed === undefined || committed.length === 0) {
@@ -1955,14 +1789,15 @@ async function verifyDisclosedContent(
1955
1789
  governed
1956
1790
  ? "disclosed content present but this hand-off record commits no " +
1957
1791
  "message_hash a reader can bind to (not a conformant hand-off " +
1958
- "record, or message_hash is not 64 lowercase hex) -- undetermined"
1792
+ "record, or message_hash is not 64 lowercase hex) -- not checked"
1959
1793
  : "disclosed content present but no committed content_hash to bind to " +
1960
- "(record withheld or record has no event.content_hash) -- undetermined",
1794
+ "(record withheld or record has no event.content_hash) -- not checked",
1961
1795
  );
1962
1796
  }
1963
1797
  } else {
1964
1798
  const got = await sha256Hex(disclosed);
1965
1799
  contentOk = committed.includes(got);
1800
+ unsigned = !contentOk && inBody === undefined;
1966
1801
  if (!contentOk) {
1967
1802
  const fieldName = governed ? "message_hash" : "content_hash";
1968
1803
  evErrors.push(
@@ -1972,28 +1807,23 @@ async function verifyDisclosedContent(
1972
1807
  }
1973
1808
  }
1974
1809
  }
1975
- return { contentOk, errors: evErrors };
1810
+ return { contentOk, unsigned, errors: evErrors };
1976
1811
  }
1977
1812
 
1978
1813
  /**
1979
1814
  * One `stamps[]` entry's result, plus the witnessed bytes it was graded on.
1980
- *
1981
- * `witnessedHex` is internal and never surfaces on `StampResult`: it is the
1982
- * whole stamp body again, and putting it back on the result would double every
1983
- * serialized verdict to carry a copy of what the bundle already holds. It
1984
- * exists so the optional on-chain re-fetch compares the chain against THE SAME
1985
- * BYTES this function hashed to derive `stamp_ref`, rather than re-reading
1986
- * `stamp_bytes` out of the body a second time — one value, so the graded bytes
1987
- * and the compared bytes cannot come apart.
1988
- *
1989
- * Undefined exactly when `stamp_bytes` was unusable, in which case the entry
1990
- * has already failed and there is nothing a gateway could confirm.
1815
+ * `witnessedHex` stays internal so the on-chain re-fetch compares the same
1816
+ * bytes that derived `stamp_ref`. Undefined when `stamp_bytes` was unusable.
1991
1817
  */
1992
1818
  interface StampEntryOutcome {
1993
1819
  result: StampResult;
1994
1820
  witnessedHex?: string;
1995
1821
  }
1996
1822
 
1823
+ /**
1824
+ * Parse and check one stamp's witnessed bytes. Errors accumulate wherever a
1825
+ * later check still means something, so independent defects are all reported.
1826
+ */
1997
1827
  async function verifyStampEntry(
1998
1828
  raw: unknown,
1999
1829
  seen: Set<string>,
@@ -2023,7 +1853,7 @@ async function verifyStampEntry(
2023
1853
  }
2024
1854
  const e = raw as Partial<StampEntry> & Record<string, unknown>;
2025
1855
 
2026
- // --- the witnessed bytes, which are the only authority in this body -------
1856
+ // The witnessed bytes are the only authority in this body.
2027
1857
  if (!isLowerHex(e.stamp_bytes)) {
2028
1858
  errors.push("stamp_bytes is missing or not lowercase hex");
2029
1859
  return { result: base };
@@ -2035,19 +1865,14 @@ async function verifyStampEntry(
2035
1865
  errors.push(`stamp_bytes is not hex${thrownDetail(err)}`);
2036
1866
  return { result: base };
2037
1867
  }
2038
- // Gated by `isLowerHex` above, so this is already the exact lowercase hex of
2039
- // the exact witnessed bytes — §5.2.2's "one authority in this body".
1868
+ // Gated by `isLowerHex` above: already the exact witnessed bytes' hex (§5.2.2).
2040
1869
  const witnessedHex: string = e.stamp_bytes;
2041
1870
 
2042
- // --- the derived identity -------------------------------------------------
2043
1871
  const derived = await sha256Hex(bytes);
2044
1872
  if (typeof e.stamp_ref !== "string" || e.stamp_ref === "") {
2045
1873
  errors.push("stamp entry missing stamp_ref");
2046
1874
  } else if (e.stamp_ref.toLowerCase() !== derived) {
2047
- // The bytes were edited and the label was not — or the label was edited
2048
- // and the bytes were not. Either way the bundle names a stamp it is not
2049
- // carrying, and quoting that name to a witness would resolve to something
2050
- // else entirely.
1875
+ // Bytes and label disagree: the bundle names a stamp it is not carrying.
2051
1876
  errors.push(
2052
1877
  `stamp_ref does not match SHA-256(stamp_bytes): claimed=${e.stamp_ref} derived=${derived}`,
2053
1878
  );
@@ -2062,7 +1887,6 @@ async function verifyStampEntry(
2062
1887
  }
2063
1888
  }
2064
1889
 
2065
- // --- the body itself ------------------------------------------------------
2066
1890
  let body: StampBody;
2067
1891
  try {
2068
1892
  const parsed: unknown = jcsParse(bytes);
@@ -2071,10 +1895,8 @@ async function verifyStampEntry(
2071
1895
  return { result: base, witnessedHex };
2072
1896
  }
2073
1897
  body = parsed as StampBody;
2074
- // Re-canonicalize and compare. The plane replays these bytes verbatim on
2075
- // every submit attempt for exactly this reason: a non-canonical body makes
2076
- // a retry a DIFFERENT submission, and makes every independent re-derivation
2077
- // of the stamp disagree with the one that was witnessed.
1898
+ // Must re-canonicalize to itself: a non-canonical body makes every
1899
+ // independent re-derivation disagree with what was witnessed.
2078
1900
  if (jcs(body as unknown as Record<string, unknown>) !== decodeUtf8Strict(bytes)) {
2079
1901
  errors.push("stamp_bytes are not JCS-canonical (RFC 8785)");
2080
1902
  return { result: base, witnessedHex };
@@ -2085,10 +1907,8 @@ async function verifyStampEntry(
2085
1907
  }
2086
1908
 
2087
1909
  if (body.stamp_version !== STAMP_BODY_VERSION) {
2088
- // A `sanning.stamp.trace/v1` body whose inner version is something else is
2089
- // two answers to one question inside one signature. The body_type is the
2090
- // reader's dispatch key and it said v1; honouring the other spelling is
2091
- // how a reader half-parses a shape it does not know.
1910
+ // The inner version must match the body type: two answers inside one
1911
+ // signature are rejected.
2092
1912
  errors.push(
2093
1913
  `unsupported stamp_version ${JSON.stringify(body.stamp_version ?? null)} in a ` +
2094
1914
  `${STAMP_TRACE_BODY_TYPE} body (expected ${STAMP_BODY_VERSION})`,
@@ -2103,7 +1923,7 @@ async function verifyStampEntry(
2103
1923
  errors.push(`stamp body ${f} is missing or not a non-empty string`);
2104
1924
  base.bodyOk = false;
2105
1925
  } else if (f !== "org_ref" && !isRfc3339UtcInstant(body[f])) {
2106
- // §5.2 (A31): the promise window is the form `signed_at` pins.
1926
+ // The promise window uses the form `signed_at` pins (§5.2).
2107
1927
  errors.push(`stamp body ${f} is not an RFC 3339 UTC date-time with Z naming a real date and time`);
2108
1928
  base.bodyOk = false;
2109
1929
  }
@@ -2125,30 +1945,26 @@ async function verifyStampEntry(
2125
1945
  base.leafCount = stampCount;
2126
1946
  }
2127
1947
 
2128
- // --- the sub-tree ranges --------------------------------------------------
2129
1948
  base.subtreesOk = checkSubtrees(body, base, errors);
2130
1949
 
2131
- // A stamp needs no malformed/absent split: the §5.2 re-fetch path already
2132
- // leaves `onChain` null whenever there is no `AnchorRef`, so it never
2133
- // synthesised the outage proof#120 is about.
2134
- base.anchorRef = parseAnchorRef(e.anchor_ref, errors).ref;
1950
+ // No malformed/absent split is needed: the re-fetch leaves `onChain` null
1951
+ // whenever there is no `AnchorRef`.
1952
+ const clean = errors.length === 0;
1953
+ const parsedRef = parseAnchorRef(e.anchor_ref, errors);
1954
+ base.anchorRef = parsedRef.ref;
2135
1955
 
2136
1956
  base.ok =
2137
1957
  base.stampRefOk && base.bodyOk && base.subtreesOk && errors.length === 0;
1958
+ base.anchorRefMalformed =
1959
+ parsedRef.malformed && clean && base.stampRefOk && base.bodyOk && base.subtreesOk;
2138
1960
  return { result: base, witnessedHex };
2139
1961
  }
2140
1962
 
2141
1963
  /**
2142
- * The range arithmetic (§5.2), and the reason it is a hard check.
2143
- *
2144
- * `buildStampTree` orders a stamp's leaves agent-major, which is the only
2145
- * reason `first_leaf` + `leaf_count` can describe an agent's slice at all: the
2146
- * ranges partition `[0, leaf_count)` exactly, by construction. A body whose
2147
- * ranges do not is either not a stamp this stack built or one built wrong, and
2148
- * in both cases the per-agent claim the ranges exist to support is unfounded.
2149
- *
2150
- * The sum is checked BEFORE the tiling and reports its own message, so a body
2151
- * that is merely short does not get filed under a gap it does not have.
1964
+ * The range arithmetic (§5.2). Leaves are agent-major, so the ranges must
1965
+ * partition `[0, leaf_count)` exactly, or the per-agent slice claims they
1966
+ * support are unfounded. The sum is checked before the tiling so a short body
1967
+ * is not reported as a gap.
2152
1968
  */
2153
1969
  function checkSubtrees(body: StampBody, out: StampResult, errors: string[]): boolean {
2154
1970
  const raw = body.subtrees;
@@ -2172,9 +1988,8 @@ function checkSubtrees(body: StampBody, out: StampResult, errors: string[]): boo
2172
1988
  errors.push(`sub-tree ${ref} first_leaf is not a non-negative integer`);
2173
1989
  shapeOk = false;
2174
1990
  }
2175
- // A zero-leaf sub-tree is not a slice of anything, and admitting one lets
2176
- // an arbitrary number of empty ranges sit inside a tiling that still adds
2177
- // up.
1991
+ // A zero-leaf range is a slice of nothing, and empty ranges could pad a
1992
+ // tiling that still adds up.
2178
1993
  if (subCount === null || subCount === 0) {
2179
1994
  errors.push(`sub-tree ${ref} leaf_count is not a positive integer`);
2180
1995
  shapeOk = false;
@@ -2198,9 +2013,7 @@ function checkSubtrees(body: StampBody, out: StampResult, errors: string[]): boo
2198
2013
  const total = out.leafCount;
2199
2014
  if (total === null) return false; // already reported by the caller
2200
2015
 
2201
- // Summed exactly. Each count is a safe integer but their sum need not be,
2202
- // and a float sum past 2**53 prints a different number than Python's
2203
- // unbounded one in the sentence below.
2016
+ // Summed exactly: the sum can pass 2**53 and must print as Python's does.
2204
2017
  const sum = subtrees.reduce((n, s) => n + BigInt(s.leafCount), 0n);
2205
2018
  if (sum !== BigInt(total)) {
2206
2019
  errors.push(
@@ -2223,10 +2036,21 @@ function checkSubtrees(body: StampBody, out: StampResult, errors: string[]): boo
2223
2036
  return true;
2224
2037
  }
2225
2038
 
2039
+ // `sanning.stamp.trace/v1`: the Sanning-signed interval stamp (§5.2).
2040
+ //
2041
+ // A stamp has no producer signature and no second Sanning signature in the
2042
+ // bundle: "Sanning signed this" is the wrapper signature, and the witness on a
2043
+ // chain is what makes it evidence. So an unwitnessed stamp holds the bundle at
2044
+ // `partial`. Offline, this checks the stamp against its witnessed bytes:
2045
+ //
2046
+ // 1. `stamp_ref` is SHA-256 of the decoded bytes.
2047
+ // 2. The bytes parse and re-canonicalize to themselves under JCS.
2048
+ // 3. Their `stamp_version` is the one this body type pins.
2049
+ // 4. The sub-tree ranges tile the stamp's leaves exactly: per-agent slice
2050
+ // claims rest on that, and nothing else in the stack would notice.
2226
2051
  async function verifyStampTrace(
2227
2052
  b: EvidenceBundle,
2228
2053
  specVersionOk: boolean,
2229
- assertedStatus: string | null,
2230
2054
  errors: string[],
2231
2055
  options: VerifyEvidenceOptions,
2232
2056
  ): Promise<EvidenceBundleResult> {
@@ -2244,13 +2068,8 @@ async function verifyStampTrace(
2244
2068
  witnessed.push(outcome.witnessedHex);
2245
2069
  }
2246
2070
 
2247
- // --- The optional on-chain re-fetch (§5.2.4's "online act") ---------------
2248
- //
2249
- // The SAME stamps[] array §5.3 carries, so it gets the same capability. The
2250
- // alternative — a stamp that can be re-fetched inside one body and not the
2251
- // other — asks every consumer to remember which wrapper it is holding before
2252
- // it can trust `onChain: null`, and that is exactly the kind of per-body
2253
- // exception a reader eventually gets wrong.
2071
+ // The optional on-chain re-fetch (§5.2.4), shared with §5.3 so `onChain:
2072
+ // null` means the same thing in both bodies.
2254
2073
  const { onChainChecked, gatewayUnavailable, uncheckedKind, uncheckedId } = await refetchStamps(
2255
2074
  stamps,
2256
2075
  witnessed,
@@ -2258,38 +2077,31 @@ async function verifyStampTrace(
2258
2077
  errors,
2259
2078
  );
2260
2079
 
2261
- // --- Rollup ----------------------------------------------------------------
2080
+ // Rollup.
2262
2081
  const anyHardFailure = stamps.some((s) => !s.ok);
2263
2082
  const anyUnwitnessed = stamps.some((s) => s.anchorRef === null);
2264
2083
 
2084
+ // A lookup that did not settle is said here and moves nothing (the witness line says it).
2085
+ pushRefetchGapNotes(errors, "stamps", {
2086
+ unavailable: gatewayUnavailable,
2087
+ kind: uncheckedKind,
2088
+ id: uncheckedId,
2089
+ });
2265
2090
  let status: EvidenceStatus;
2266
2091
  if (anyHardFailure) {
2267
2092
  status = "failed";
2268
2093
  } else if (stamps.length === 0) {
2269
- // A bundle asserting nothing. Not a failure — nothing here is wrong — but
2270
- // it must never read `verified`, which a caller would take as a witness it
2271
- // does not have.
2272
- status = "partial";
2094
+ // Asserts nothing: not a failure, but never `verified`.
2095
+ status = "incomplete";
2273
2096
  errors.push(`${STAMP_TRACE_BODY_TYPE} body carries no stamps`);
2274
- } else if (gatewayUnavailable || uncheckedKind || uncheckedId || anyUnwitnessed) {
2275
- // Both hold the bundle at `partial`, and both get said — see §5.3's rollup
2276
- // for why one note is not enough when there are two different gaps.
2277
- status = "partial";
2278
- pushRefetchGapNotes(errors, "stamps", {
2279
- unavailable: gatewayUnavailable,
2280
- kind: uncheckedKind,
2281
- id: uncheckedId,
2282
- });
2283
- if (anyUnwitnessed) {
2284
- // The honest half of P-10, one layer up. A stamp with no `anchor_ref` is
2285
- // sealed and unwitnessed: `retained-by-sanning`, which is a statement
2286
- // about our coverage and never an accusation about their evidence. It is
2287
- // also not `verified`, because for a stamp the witness is the entire
2288
- // independent part — unlike an anchor-trace checkpoint, which still
2289
- // carries a producer signature and an inclusion proof that stand on
2290
- // their own offline.
2097
+ } else if (anyUnwitnessed) {
2098
+ status = "incomplete";
2099
+ {
2100
+ // A stamp with no `anchor_ref` is sealed and unwitnessed: a statement
2101
+ // about Sanning's coverage, never an accusation. Not `verified` either:
2102
+ // for a stamp the witness is the whole independent part.
2291
2103
  errors.push(
2292
- "one or more stamps carries no anchor_ref — sealed by Sanning, witnessed by nobody",
2104
+ "one or more stamps carries no anchor_ref: sealed by Sanning, witnessed by nobody",
2293
2105
  );
2294
2106
  }
2295
2107
  } else {
@@ -2305,31 +2117,19 @@ async function verifyStampTrace(
2305
2117
  onChainChecked,
2306
2118
  checkpoints: [],
2307
2119
  events: [],
2308
- assertedStatus,
2309
2120
  stamps,
2310
2121
  errors,
2311
2122
  };
2312
2123
  }
2313
2124
 
2314
- // ===========================================================================
2315
- // `sanning.stamp.evidence/v2`: the two halves, bound (evidence-bundle.md §5.3)
2316
- //
2317
- // The stamp half is §5.2's code unchanged (`verifyStampEntry`, which carries
2318
- // the sub-tree partition check). The event half reuses the same primitives the
2319
- // anchor-trace path uses — `verifyEnvelope`, `verifyInclusion`, `leafHash`,
2320
- // `verifyDisclosedContent` — because a second copy of Merkle verification is a
2321
- // second place for it to drift.
2322
- //
2323
- // What differs, and only this: the join. An event resolves `inclusion.stamp_ref`
2324
- // to a stamps[] entry and walks its audit path to the root committed INSIDE
2325
- // that stamp's witnessed bytes. There is no `merkle_root` field on the event to
2326
- // read instead — repeating the root beside its own authoritative bytes would be
2327
- // two answers to one question inside one signature (§5.1.2).
2328
- // ===========================================================================
2125
+ // `sanning.stamp.evidence/v2`: both halves, bound (evidence-bundle.md §5.3).
2126
+ // The stamp half is `verifyStampEntry`; the event half reuses the anchor-trace
2127
+ // primitives. Only the join differs: an event resolves `inclusion.stamp_ref`
2128
+ // and walks to the root inside that stamp's witnessed bytes. There is no
2129
+ // `merkle_root` on the event, which would be a second answer (§5.1.2).
2329
2130
  async function verifyStampEvidence(
2330
2131
  b: EvidenceBundle,
2331
2132
  specVersionOk: boolean,
2332
- assertedStatus: string | null,
2333
2133
  errors: string[],
2334
2134
  options: VerifyEvidenceOptions,
2335
2135
  ): Promise<EvidenceBundleResult> {
@@ -2342,7 +2142,7 @@ async function verifyStampEvidence(
2342
2142
  );
2343
2143
  }
2344
2144
 
2345
- // --- The stamp half: §5.2's rules, §5.2's code ----------------------------
2145
+ // The stamp half.
2346
2146
  const seen = new Set<string>();
2347
2147
  const stamps: StampResult[] = [];
2348
2148
  const witnessed: (string | undefined)[] = [];
@@ -2352,31 +2152,32 @@ async function verifyStampEvidence(
2352
2152
  witnessed.push(outcome.witnessedHex);
2353
2153
  }
2354
2154
 
2355
- // The join table. ONLY a stamp whose derived ref and witnessed bytes both
2356
- // checked out is joinable: a root read out of bytes that failed their own
2357
- // verification is not a root anyone should walk a proof to, and admitting one
2358
- // would let a broken stamp lend its authority to an event.
2155
+ // Only a stamp whose derived ref and witnessed bytes both checked out is
2156
+ // joinable: a failed stamp must not lend its root to an event.
2359
2157
  const byRef = new Map<string, StampResult>();
2360
2158
  for (const st of stamps) {
2361
2159
  if (st.stampRef && st.stampRefOk && st.bodyOk) byRef.set(st.stampRef, st);
2362
2160
  }
2161
+ // Every ref a stamp entry claims or derives. A ref outside it names a stamp the pack
2162
+ // does not hold, which is a stamp missing, never a stamp proven wrong (section 5.3.3
2163
+ // rule 1).
2164
+ const heldRefs = new Set<string>();
2165
+ for (const st of stamps) if (st.stampRef) heldRefs.add(st.stampRef);
2166
+ for (const raw of body.stamps as unknown[]) {
2167
+ if (raw !== null && typeof raw === "object" && !Array.isArray(raw)) {
2168
+ const claimed = (raw as Record<string, unknown>).stamp_ref;
2169
+ if (typeof claimed === "string") heldRefs.add(claimed);
2170
+ }
2171
+ }
2363
2172
 
2364
- // --- The event half -------------------------------------------------------
2173
+ // The event half.
2365
2174
  const eventResults: EventResult[] = [];
2175
+ const agentItems: (AgentHeldRecord | null)[] = [];
2366
2176
  for (const rawEv of body.events) {
2367
2177
  const evErrors: string[] = [];
2368
- // A verifier must never RAISE on adversarial input — a caller handed a
2369
- // stack trace instead of a verdict cannot tell a malformed pack from a
2370
- // broken tool. `events[]` is attacker-shaped, so a null/non-object entry
2371
- // is coerced to an empty event and fails through the normal path, exactly
2372
- // as the Python kernel does. Found by adversarial review, not by the
2373
- // cross-kernel leg — whose cases did not include one, and now do.
2178
+ // `events[]` is attacker-shaped; a verifier never raises on it.
2374
2179
  if (rawEv === null || typeof rawEv !== "object" || Array.isArray(rawEv)) {
2375
- // NOT an event. Graded and reported as such rather than coerced to an
2376
- // empty one: probing verifyEnvelope with nothing asks the envelope layer
2377
- // a question about an object that was never an envelope, and the two
2378
- // kernels answer it differently (undetermined vs failed) — a real
2379
- // divergence, but not this body's to resolve. Reported separately.
2180
+ // Graded as not an event, never coerced to an empty one.
2380
2181
  eventResults.push({
2381
2182
  eventId: "<unknown>",
2382
2183
  envelopeOk: false,
@@ -2386,34 +2187,23 @@ async function verifyStampEvidence(
2386
2187
  contentOk: null,
2387
2188
  ok: false,
2388
2189
  errors: ["events[] entry is not an object"],
2190
+ agent: null,
2389
2191
  });
2192
+ agentItems.push(null);
2390
2193
  continue;
2391
2194
  }
2392
2195
  const ev = rawEv as StampEvidenceEvent;
2393
2196
  const eventId = getEventId(ev.envelope);
2394
2197
  let envelopeOk = false;
2198
+ let envelopeFaults: EnvelopeFault[] = [];
2395
2199
  let payloadBindingOk: boolean | null = null;
2396
2200
 
2397
- // A retired field name under this major is a reject, never an ignored
2398
- // unknown field: ignored, it reads as a withheld record (W-3).
2201
+ // A retired field name is a reject: ignored, it would read as withheld.
2399
2202
  const legacyError = legacyEventBytesError(ev, "event", STAMP_EVIDENCE_BODY_TYPE);
2400
2203
  if (legacyError !== null) evErrors.push(legacyError);
2401
2204
 
2402
- // MALFORMED IS A FAILURE; ABSENT IS UNDETERMINED (proof#85).
2403
- //
2404
- // `event_bytes` that will not decode leaves `eventBytes` undefined, and an
2405
- // undefined `eventBytes` is exactly how a WITHHELD record is spelled one
2406
- // line down. So an event carrying garbage in place of its record used to
2407
- // reach the same tri-state as an event carrying nothing: `payloadHashOk`
2408
- // null, `ok` true, a leading tick on the line, and a `verified` rollup over
2409
- // a record nobody could read. The only thing wrong with the pack was the
2410
- // bytes, and it was the one thing the verdict did not say.
2411
- //
2412
- // A record the producer chose not to disclose is the customer exercising
2413
- // minimum disclosure. A record that is present and unreadable is a defect
2414
- // in the artifact, and this kernel's standing rule (the same one
2415
- // `checkpoints[]` already applies through `keyed.errors`) is that a defect
2416
- // we can see fails.
2205
+ // Malformed is a failure; absent is undetermined. Undecodable `event_bytes`
2206
+ // would otherwise look exactly like a withheld record and verify.
2417
2207
  let eventBytes: Uint8Array | undefined;
2418
2208
  let eventBytesMalformed = false;
2419
2209
  try {
@@ -2422,10 +2212,10 @@ async function verifyStampEvidence(
2422
2212
  eventBytesMalformed = true;
2423
2213
  evErrors.push(`event event_bytes is not hex${thrownDetail(e)}`);
2424
2214
  }
2425
- const duplicate = recordDuplicateKey(eventBytes);
2426
- if (duplicate !== undefined) {
2215
+ const notJson = recordNotJson(eventBytes);
2216
+ if (notJson !== undefined) {
2427
2217
  eventBytesMalformed = true;
2428
- evErrors.push(`event record is not JSON: ${duplicate}`);
2218
+ evErrors.push(notJson);
2429
2219
  }
2430
2220
  try {
2431
2221
  const res = await verifyEnvelope(
@@ -2433,16 +2223,12 @@ async function verifyStampEvidence(
2433
2223
  eventBytes !== undefined ? { payloadBytes: eventBytes } : {},
2434
2224
  );
2435
2225
  const hexError = envelopeHexError("event", ev.envelope);
2436
- // tolerates an undetermined binding (withheld record)
2226
+ // Tolerates an undetermined binding (withheld record).
2437
2227
  envelopeOk = res.ok && hexError === null;
2228
+ envelopeFaults = eventEnvelopeFaults(res.faults, hexError);
2438
2229
  payloadBindingOk = res.payloadHashOk;
2439
- // P-10: report what could not be determined, never a fault that was never
2440
- // observed. There is no envelope here, so no signature was checked and no
2441
- // binding was attempted. "signature failed" named a check that never ran,
2442
- // and it was the only thing said about an event whose undetermined
2443
- // binding then rendered as a WITHHELD record, a claim about the
2444
- // producer's disclosure choice out of an input that says nothing about
2445
- // the producer at all (proof#76).
2230
+ // No envelope, so no signature was checked: report it as malformed, not as
2231
+ // a failed signature or a withheld record.
2446
2232
  if (!isJsonObject(ev.envelope)) {
2447
2233
  evErrors.push("event envelope is not a JSON object");
2448
2234
  } else if (hexError !== null) {
@@ -2451,24 +2237,23 @@ async function verifyStampEvidence(
2451
2237
  evErrors.push("event envelope signature failed");
2452
2238
  }
2453
2239
  if (res.payloadHashOk === false) evErrors.push("event record does not bind to payload_hash");
2454
- // A malformed skeleton field fails the envelope with a valid signature
2455
- // and binding (proof#95), so without this the event fails with no reason.
2240
+ // Name a malformed skeleton field, which fails an otherwise valid envelope.
2456
2241
  for (const m of res.errors) if (m.startsWith("malformed ")) evErrors.push(`event envelope ${m}`);
2242
+ if (res.errors.some((m) => m.startsWith("missing payload_hash"))) evErrors.push("event envelope missing payload_hash");
2457
2243
  const specFinding = specVersionFinding("event", ev.envelope, res);
2458
2244
  if (specFinding !== null) evErrors.push(specFinding);
2459
2245
  } catch (e) {
2460
2246
  evErrors.push(`event envelope verify error${thrownDetail(e)}`);
2461
2247
  }
2462
2248
 
2463
- // --- The join (§5.3.3 rule 1) -------------------------------------------
2249
+ // The join (§5.3.3 rule 1).
2464
2250
  const incl = ev.inclusion as (StampEvidenceInclusion & Record<string, unknown>) | undefined;
2465
2251
  let st: StampResult | undefined;
2252
+ let stampAbsent = false;
2466
2253
  if (!incl || typeof incl !== "object") {
2467
2254
  evErrors.push("event missing inclusion proof");
2468
2255
  } else if (incl.checkpoint_ref !== undefined || incl.checkpoint_tx_id !== undefined) {
2469
- // One bundle speaks one vocabulary. An inclusion carrying an anchor-trace
2470
- // join key inside this body is a mixed bundle, and resolving it anyway is
2471
- // how an event silently binds to something nobody meant (§5.1.2).
2256
+ // An anchor-trace join key inside this body is a mixed bundle (§5.1.2).
2472
2257
  evErrors.push(
2473
2258
  `event inclusion carries an anchor-trace join key in a ${STAMP_EVIDENCE_BODY_TYPE} body`,
2474
2259
  );
@@ -2476,9 +2261,14 @@ async function verifyStampEvidence(
2476
2261
  evErrors.push("event inclusion missing stamp_ref");
2477
2262
  } else {
2478
2263
  st = byRef.get(incl.stamp_ref);
2479
- if (st === undefined) {
2480
- // Hard reject, never undetermined: without a resolved stamp the
2481
- // inclusion proof has no root to check against at all.
2264
+ if (st === undefined && !heldRefs.has(incl.stamp_ref)) {
2265
+ // The pack does not hold the stamp the record names: the inclusion has no root to
2266
+ // check against, so `witnessed` is not proven (`missing`).
2267
+ stampAbsent = true;
2268
+ evErrors.push(
2269
+ `event inclusion.stamp_ref ${JSON.stringify(incl.stamp_ref)} names a stamp this pack does not hold`,
2270
+ );
2271
+ } else if (st === undefined) {
2482
2272
  evErrors.push(
2483
2273
  `event inclusion.stamp_ref ${JSON.stringify(incl.stamp_ref)} resolves to no verified stamp`,
2484
2274
  );
@@ -2488,15 +2278,9 @@ async function verifyStampEvidence(
2488
2278
 
2489
2279
  let inclusionOk = false;
2490
2280
  if (incl && stampBound && st) {
2491
- // Rule 3: the tree walked must be the tree the stamp committed to. A
2492
- // proof walked against a different leaf_count is a proof about a
2493
- // different tree, and RFC 9162's path shape depends on the size.
2494
- //
2495
- // Read BY VALUE, as the anchor-trace path reads them (A38), and as the
2496
- // Python kernel does (`_safe_integer`): the wrapper signs JCS(body),
2497
- // which writes `3.0` as `3`, so a stated `3.0` is 3 under the same
2498
- // signature. Anything that is not a safe integer is malformed, with a
2499
- // sentence that prints no value.
2281
+ // Rule 3: the tree walked must be the tree the stamp committed to; RFC
2282
+ // 9162's path shape depends on the size. Read by value, as JCS signs it
2283
+ // (`3.0` is 3); anything not a safe integer is malformed.
2500
2284
  const statedCount = safeInteger(incl.leaf_count);
2501
2285
  const statedIndex = safeInteger(incl.leaf_index);
2502
2286
  if (statedCount === null) {
@@ -2521,9 +2305,7 @@ async function verifyStampEvidence(
2521
2305
  evErrors.push("RFC 9162 inclusion proof did not reconstruct the stamp root");
2522
2306
  }
2523
2307
 
2524
- // Defense in depth, as §5.1: the claimed leaf MUST be this event's
2525
- // own signed envelope bytes, or a valid proof for an UNRELATED leaf
2526
- // could be smuggled in.
2308
+ // As §5.1: the claimed leaf must be this event's own signed envelope.
2527
2309
  const expectedLeafHex = toHex(await leafHash(utf8(jcs(ev.envelope))));
2528
2310
  if (expectedLeafHex !== (incl.leaf_hash ?? "").toLowerCase()) {
2529
2311
  inclusionOk = false;
@@ -2540,17 +2322,19 @@ async function verifyStampEvidence(
2540
2322
  const contentRes = await verifyDisclosedContent(ev, eventId, eventBytes, options.content);
2541
2323
  const contentOk = contentRes.contentOk;
2542
2324
  evErrors.push(...contentRes.errors);
2325
+ agentItems.push(
2326
+ agentItem(ev, eventBytes, envelopeOk && payloadBindingOk === true && !eventBytesMalformed),
2327
+ );
2543
2328
 
2544
2329
  eventResults.push({
2545
2330
  eventId,
2546
2331
  envelopeOk,
2547
2332
  payloadBindingOk,
2548
2333
  inclusionOk,
2549
- // Named for §5.1's join; here it reports whether `stamp_ref` resolved.
2334
+ // Here: whether `stamp_ref` resolved.
2550
2335
  checkpointBound: stampBound,
2551
2336
  contentOk,
2552
- // `!eventBytesMalformed`: present-and-unreadable record bytes are a
2553
- // defect in the artifact, never a disclosure choice (proof#85).
2337
+ // Unreadable record bytes are a defect, never a disclosure choice.
2554
2338
  ok:
2555
2339
  envelopeOk &&
2556
2340
  inclusionOk &&
@@ -2559,10 +2343,19 @@ async function verifyStampEvidence(
2559
2343
  legacyError === null &&
2560
2344
  !eventBytesMalformed,
2561
2345
  errors: evErrors,
2346
+ agent: null,
2347
+ envelopeFault: envelopeFaults[0] ?? null,
2348
+ envelopeFaults,
2349
+ recordNotJson: notJson !== undefined && envelopeOk && payloadBindingOk === true,
2350
+ contentUnsigned: contentOk === false && contentRes.unsigned,
2351
+ stampAbsent,
2352
+ ownFormFailed: legacyError !== null || eventBytesMalformed,
2562
2353
  });
2563
2354
  }
2564
2355
 
2565
- // --- The optional on-chain re-fetch (§5.2.4's "online act") ---------------
2356
+ await attachAgentReadings(eventResults, agentItems);
2357
+
2358
+ // The optional on-chain re-fetch (§5.2.4).
2566
2359
  const { onChainChecked, gatewayUnavailable, uncheckedKind, uncheckedId } = await refetchStamps(
2567
2360
  stamps,
2568
2361
  witnessed,
@@ -2570,49 +2363,39 @@ async function verifyStampEvidence(
2570
2363
  errors,
2571
2364
  );
2572
2365
 
2573
- // --- Rollup ---------------------------------------------------------------
2574
- // Rule 4: a declared event MUST be checked. `events[]` present and unverified
2575
- // is the false green this body exists to prevent, so an event that did not
2576
- // verify fails the bundle — it is never rolled up as verified.
2577
- const anyHardFailure = stamps.some((st) => !st.ok) || eventResults.some((e) => !e.ok);
2366
+ // Rollup. Rule 4: a declared event must be checked, so an event that did
2367
+ // not verify fails the bundle.
2368
+ const anyHardFailure =
2369
+ stamps.some((st) => !st.ok) || eventResults.some((e) => !e.ok && !unproven(e));
2578
2370
  const anyUnwitnessed = stamps.some((st) => st.anchorRef === null);
2371
+ const anyStampAbsent = eventResults.some((e) => e.stampAbsent === true);
2579
2372
 
2373
+ pushRefetchGapNotes(errors, "stamps", {
2374
+ unavailable: gatewayUnavailable,
2375
+ kind: uncheckedKind,
2376
+ id: uncheckedId,
2377
+ });
2580
2378
  let status: EvidenceStatus;
2581
2379
  if (anyHardFailure) {
2582
2380
  status = "failed";
2583
2381
  } else if (stamps.length === 0 && eventResults.length === 0) {
2584
- status = "partial";
2382
+ status = "incomplete";
2585
2383
  errors.push(`${STAMP_EVIDENCE_BODY_TYPE} body carries no stamps and no events`);
2586
- } else if (
2587
- eventResults.length === 0 ||
2588
- gatewayUnavailable ||
2589
- uncheckedKind ||
2590
- uncheckedId ||
2591
- anyUnwitnessed
2592
- ) {
2593
- // Each holds the bundle at `partial`, and EACH gets said. A pack with no
2594
- // events, a stamp nobody witnessed and a witness we could not reach are
2595
- // different gaps with different fixes, and an auditor told only about one
2596
- // would read the others as fine. The order is fixed so the two kernels
2597
- // emit the same list.
2598
- status = "partial";
2384
+ } else if (eventResults.length === 0 || anyUnwitnessed || anyStampAbsent || eventResults.some(unproven)) {
2385
+ // Each gap holds the bundle at `incomplete` and each is named, in a fixed order
2386
+ // so both kernels emit the same list.
2387
+ status = "incomplete";
2599
2388
  if (eventResults.length === 0) {
2600
- // §5.3.2: an events-empty body is exactly a §5.2 stamp trace, and is
2601
- // partial, never verified. It says nothing about any event.
2389
+ // Section 5.3.2: an events-empty body is a section 5.2 stamp trace: incomplete, never
2390
+ // verified.
2602
2391
  errors.push(
2603
2392
  `${STAMP_EVIDENCE_BODY_TYPE} body carries no events -- it evidences no event, only its stamps`,
2604
2393
  );
2605
2394
  }
2606
- pushRefetchGapNotes(errors, "stamps", {
2607
- unavailable: gatewayUnavailable,
2608
- kind: uncheckedKind,
2609
- id: uncheckedId,
2610
- });
2611
2395
  if (anyUnwitnessed) {
2612
- // §5.3.3 rule 6, inherited whole from §5.2.4: for a stamp the witness IS
2613
- // the independent part, so the events resting on it inherit `partial`.
2396
+ // Section 5.3.3 rule 6: events resting on an unwitnessed stamp inherit `incomplete`.
2614
2397
  errors.push(
2615
- "one or more stamps carries no anchor_ref — sealed by Sanning, witnessed by nobody",
2398
+ "one or more stamps carries no anchor_ref: sealed by Sanning, witnessed by nobody",
2616
2399
  );
2617
2400
  }
2618
2401
  } else {
@@ -2628,7 +2411,6 @@ async function verifyStampEvidence(
2628
2411
  onChainChecked,
2629
2412
  checkpoints: [],
2630
2413
  events: eventResults,
2631
- assertedStatus,
2632
2414
  stamps,
2633
2415
  errors,
2634
2416
  };
@@ -2636,37 +2418,10 @@ async function verifyStampEvidence(
2636
2418
 
2637
2419
  /**
2638
2420
  * The optional on-chain re-fetch over `stamps[]`, shared by §5.2 and §5.3.
2639
- *
2640
- * ── Why the results land on `stamps[]` and `checkpoints` stays empty ──
2641
- *
2642
- * A stamp body carries no checkpoints. Synthesising `CheckpointResult`s to
2643
- * carry these outcomes would make a stamp indistinguishable from a checkpoint
2644
- * in every consumer that reads `checkpoints[]` — the exact conflation §5.2.1
2645
- * and §5.3.1 refused twice, most sharply in "a Sanning attestation
2646
- * indistinguishable on the wire from a producer's". They are different
2647
- * artifacts with different signers and different meanings when absent, and a
2648
- * reader must never have to guess which one it is holding. So the stamp
2649
- * results go on the stamps, and `checkpoints: []` keeps saying the true thing:
2650
- * this body has none.
2651
- *
2652
- * ── Why an unwitnessed stamp gets `onChain: null` and NOT all-unreachable ──
2653
- *
2654
- * The anchor-trace path synthesises an all-`unreachable` result for a
2655
- * checkpoint with no `anchor_ref`. That is not copied here, for two reasons.
2656
- *
2657
- * It would be untrue: nothing was asked of any gateway, so reporting that
2658
- * every gateway failed to answer invents a network event. "Not checked" and
2659
- * "we asked and nobody answered" are different facts, and collapsing them is
2660
- * precisely the defect that had a surface reporting an unreachable gateway
2661
- * when it had contacted none.
2662
- *
2663
- * And it would be load-bearing in the wrong direction. Over there the
2664
- * synthesis exists to drive `gatewayUnavailable`, which is what downgrades that
2665
- * run to `partial`. Here §5.2.4 already downgrades an unwitnessed stamp to
2666
- * `partial` on its own, with a better error. Copying the synthesis would add
2667
- * nothing to the status, which is `partial` either way, and would add an
2668
- * unreachable-gateway note for a network event that never happened, based on
2669
- * nothing but whether the caller happened to pass a gateway list.
2421
+ * Results land on the stamps and `checkpoints` stays empty: a stamp must never
2422
+ * be mistaken for a producer checkpoint. An unwitnessed stamp gets `onChain:
2423
+ * null`, not a synthesized all-unreachable result: no gateway was asked, and
2424
+ * §5.2.4 already holds it at `partial`.
2670
2425
  */
2671
2426
  async function refetchStamps(
2672
2427
  stamps: StampResult[],
@@ -2693,8 +2448,7 @@ async function refetchStamps(
2693
2448
  for (let i = 0; i < stamps.length; i++) {
2694
2449
  const st = stamps[i]!;
2695
2450
  const witnessedHex = witnessed[i];
2696
- // No witness named, or bytes we could not read: nothing to re-fetch, and
2697
- // nothing a gateway could confirm or deny. Left as `onChain: null`.
2451
+ // No witness named, or unreadable bytes: nothing to re-fetch.
2698
2452
  if (st.anchorRef === null || witnessedHex === undefined) continue;
2699
2453
  if (st.anchorRef.kind === SIMULATED_WITNESS_KIND) simulated++;
2700
2454
  else onChainKinds++;
@@ -2706,22 +2460,13 @@ async function refetchStamps(
2706
2460
  if (outcome.unchecked === "kind") uncheckedKind = true;
2707
2461
  if (outcome.unchecked === "id") uncheckedId = true;
2708
2462
  if (outcome.error) st.errors.push(outcome.error);
2709
- // Fold the on-chain dimension in exactly as a checkpoint does: a MISMATCH
2710
- // (false) fails the stamp — the chain does not hold what the bundle says
2711
- // was witnessed. UNREACHABLE (null) leaves an offline-sound stamp ok and
2712
- // downgrades the run to `partial` via gatewayUnavailable. A confirm (true)
2713
- // changes nothing that was not already true offline.
2714
- //
2715
- // `st.ok` is kept and narrowed rather than recomputed: its offline form
2716
- // includes `errors.length === 0`, and re-evaluating that AFTER pushing an
2717
- // unreachable note would fail a stamp for a gateway being down.
2463
+ // As for a checkpoint: a mismatch fails the stamp, unreachable downgrades
2464
+ // the run to `partial`. Narrowed, not recomputed, so an unreachable note
2465
+ // does not fail the stamp.
2718
2466
  st.ok = st.ok && st.onChainOk !== false;
2719
2467
  }
2720
- // A simulated witness is a sandbox plane's stand-in for a chain: there is no
2721
- // chain to look it up on, so it is never counted as checked on one.
2722
- // It stays not checked like any kind this verifier has no adapter for, and
2723
- // the run stays undetermined; what changes is that a run whose only
2724
- // witnesses are simulated no longer says it checked anything on a chain.
2468
+ // A simulated witness has no chain to look up, so it is never counted as
2469
+ // checked on one; the run stays undetermined.
2725
2470
  if (simulated > 0 && onChainKinds === 0) {
2726
2471
  return { onChainChecked: false, gatewayUnavailable, uncheckedKind, uncheckedId };
2727
2472
  }
@@ -2742,78 +2487,54 @@ interface StampRefetchSummary {
2742
2487
  uncheckedId: boolean;
2743
2488
  }
2744
2489
 
2745
- // ===========================================================================
2746
- // Ingest: materializing the disclosed raw bytes (evidence-bundle.md §5.1).
2747
- //
2748
- // The receiving half of a handoff. `events[].content` rides as lowercase hex
2749
- // (JSON cannot carry arbitrary bytes), and until now every consumer that wanted
2750
- // the actual log bytes back had to know that and decode them itself —
2751
- // re-implementing an encoding convention the kernel already applies internally.
2752
- // A consumer that copied `content` VERBATIM into a file shipped packs that
2753
- // failed verification on genuine, untampered evidence, because the hash of the
2754
- // hex text is not the hash of the bytes it encodes.
2755
- //
2756
- // `disclosedContent` closes that gap with one rule: bytes come back only when
2757
- // they are BOUND. It verifies the bundle first and hands back a decoded
2758
- // Uint8Array only for events whose whole chain held AND whose disclosed bytes
2759
- // hash to the committed `content_hash`. Everything else comes back as a status
2760
- // with a reason and NO bytes — in particular a minimal-disclosure event reports
2761
- // `not_disclosed` rather than empty bytes, because a caller that cannot tell
2762
- // those apart writes an empty log file and believes it succeeded.
2763
- // ===========================================================================
2490
+ // Ingest (evidence-bundle.md §5.1): `events[].content` rides as hex, and
2491
+ // `disclosedContent` hands back decoded bytes only when they are bound: the
2492
+ // event's chain held and the bytes hash to the committed `content_hash`.
2493
+ // Everything else returns a status with a reason and no bytes; minimal
2494
+ // disclosure is `not_disclosed`, never empty bytes.
2764
2495
 
2765
2496
  // Per-event outcome of an ingest attempt. Only `verified` carries bytes.
2766
2497
  export type DisclosureStatus =
2767
- | "verified" // bytes decoded AND bound to the committed content_hash
2768
- | "not_disclosed" // nothing disclosed for this event — minimal disclosure, nothing to write
2769
- | "undetermined" // bytes disclosed but nothing to bind them to (record withheld / no commitment)
2770
- | "failed"; // the event's chain did not verify, or the disclosure did not bind/decode
2498
+ | "verified" // decoded and bound to the committed content_hash
2499
+ | "not_disclosed" // nothing disclosed: minimal disclosure
2500
+ | "undetermined" // disclosed, but nothing to bind to
2501
+ | "failed"; // the event did not verify, or the disclosure did not bind or decode
2771
2502
 
2772
2503
  export interface DisclosedEventContent {
2773
2504
  eventId: string;
2774
2505
  status: DisclosureStatus;
2775
- // The decoded raw bytes. Non-null ONLY when status === "verified" — an
2776
- // unbound or unverified disclosure is withheld, never handed back.
2506
+ // Non-null only when status is "verified": unbound bytes are never handed back.
2777
2507
  bytes: Uint8Array | null;
2778
- // Why, whenever bytes are absent for a reason other than plain non-disclosure.
2508
+ // Why, when bytes are absent for a reason other than non-disclosure.
2779
2509
  errors: string[];
2780
2510
  }
2781
2511
 
2782
2512
  export interface DisclosedContentResult {
2783
2513
  // The bundle-level rollup, verbatim from verifyEvidenceBundle.
2784
2514
  status: EvidenceStatus;
2785
- // The full verification result, so a caller verifies ONCE: render the verdict
2786
- // from this and materialize from `content` in the same pass.
2515
+ // The full result, so a caller verifies once and renders from it.
2787
2516
  verification: EvidenceBundleResult;
2788
2517
  // One entry per event in the bundle body, in body order.
2789
2518
  events: DisclosedEventContent[];
2790
- // event_id → verified raw bytes: the materialization map. Contains ONLY
2791
- // entries whose status is "verified", so `for (const [id, bytes] of
2792
- // result.content)` is always safe to write straight to disk.
2519
+ // event_id to verified bytes. Only "verified" entries, so it is always safe
2520
+ // to write straight to disk.
2793
2521
  content: Map<string, Uint8Array>;
2794
2522
  // Bundle-level reasons no content could be materialized at all.
2795
2523
  errors: string[];
2796
2524
  }
2797
2525
 
2798
- // How many `sanning.evidence.export/v2` wrappers deep the source bundle may be
2799
- // nested before this path gives up. verifyEvidenceBundle's canonicalization
2800
- // depth bound already rejects a pathological bundle, but the unwrap must be
2801
- // independently bounded rather than trusting that it ran first.
2526
+ // How many export wrappers deep the source bundle may nest. Bounded here
2527
+ // independently of the canonicalization depth bound.
2802
2528
  const MAX_EXPORT_UNWRAP = 4;
2803
2529
 
2804
2530
  /**
2805
- * Materialize the disclosed raw bytes of a received bundle (evidence-bundle §5.1).
2806
- *
2807
- * Verify-then-return: the bundle is verified with {@link verifyEvidenceBundle}
2808
- * (same options — gateways, out-of-band `content`, `fetchImpl`), and an event's
2809
- * bytes are returned ONLY when that verification bound them to the committed
2810
- * `content_hash`. There is no path through this function that yields bytes
2811
- * whose SHA-256 is not the commitment.
2531
+ * Materialize the disclosed raw bytes of a received bundle (evidence-bundle.md
2532
+ * §5.1). The bundle is verified with {@link verifyEvidenceBundle}, and an
2533
+ * event's bytes are returned only when that verification bound them to the
2534
+ * committed `content_hash`.
2812
2535
  *
2813
- * Returns `Uint8Array`, never a string: the spec commits to raw bytes, and a
2814
- * UTF-8 decode is lossy for any payload that is not valid UTF-8 — re-encoding
2815
- * such a string would produce different bytes and a different hash. Callers who
2816
- * know their payload is text decode it themselves and own that assumption.
2536
+ * Returns `Uint8Array`, never a string: a UTF-8 decode is lossy and would
2537
+ * change the hash.
2817
2538
  */
2818
2539
  export async function disclosedContent(
2819
2540
  bundle: unknown,
@@ -2841,11 +2562,9 @@ export async function disclosedContent(
2841
2562
  return empty();
2842
2563
  }
2843
2564
 
2844
- // Fail closed on any drift between the body we are decoding and the results
2845
- // we are trusting. Both must be the same events in the same order, or the
2846
- // "these bytes are the bytes that verified" guarantee is void — which is the
2847
- // only guarantee this function makes. A broken wrapper lands here too:
2848
- // verifyEvidenceBundle returns zero event results, so nothing is emitted.
2565
+ // Fail closed unless the decoded body and the trusted results are the same
2566
+ // events in the same order. A broken wrapper yields zero results, so nothing
2567
+ // is emitted.
2849
2568
  if (traceEvents.length !== verification.events.length) {
2850
2569
  errors.push(
2851
2570
  "the bundle did not verify far enough to materialize content: " +
@@ -2874,18 +2593,16 @@ export async function disclosedContent(
2874
2593
  let status: DisclosureStatus;
2875
2594
 
2876
2595
  if (!res.ok || res.contentOk === false) {
2877
- // The event itself is not trustworthy (bad signature, broken inclusion,
2878
- // unbound checkpoint) or its disclosure contradicts the commitment.
2596
+ // The event is not trustworthy, or its disclosure contradicts the commitment.
2879
2597
  status = "failed";
2880
2598
  evErrors.push(
2881
2599
  res.contentOk === false
2882
- ? "disclosed content does not bind to the committed content_hash — withheld"
2883
- : "the event's own proof chain did not verify — nothing here is safe to materialize",
2600
+ ? "disclosed content does not bind to the committed content_hash; withheld"
2601
+ : "the event's own proof chain did not verify; nothing here is safe to materialize",
2884
2602
  );
2885
2603
  evErrors.push(...res.errors);
2886
2604
  } else if (!selection.offered) {
2887
- // Minimal disclosure: the committed hash is there, the bytes are not.
2888
- // Distinct from empty bytes, and never an error.
2605
+ // Minimal disclosure: distinct from empty bytes, and never an error.
2889
2606
  status = "not_disclosed";
2890
2607
  } else if (bytes === undefined) {
2891
2608
  status = "failed";
@@ -2894,12 +2611,10 @@ export async function disclosedContent(
2894
2611
  );
2895
2612
  evErrors.push(...selection.errors);
2896
2613
  } else if (res.contentOk !== true) {
2897
- // Disclosed and decodable, but there is no commitment to check it against
2898
- // (withheld record, or a record committing no content_hash). Unbound
2899
- // bytes are exactly what a consumer must not silently write out.
2614
+ // Nothing to check the bytes against: unbound bytes must not be written.
2900
2615
  status = "undetermined";
2901
2616
  evErrors.push(
2902
- "disclosed bytes are not bound to any committed content_hash — withheld rather than returned",
2617
+ "disclosed bytes are not bound to any committed content_hash; withheld rather than returned",
2903
2618
  );
2904
2619
  evErrors.push(...res.errors);
2905
2620
  } else {
@@ -2911,7 +2626,7 @@ export async function disclosedContent(
2911
2626
  if (content.has(res.eventId)) {
2912
2627
  errors.push(
2913
2628
  `duplicate event_id ${JSON.stringify(res.eventId)}: the content map keeps the first ` +
2914
- "occurrence — read events[] for every event",
2629
+ "occurrence; read events[] for every event",
2915
2630
  );
2916
2631
  } else {
2917
2632
  content.set(res.eventId, bytes);
@@ -2928,35 +2643,18 @@ export async function disclosedContent(
2928
2643
  return { status: verification.status, verification, events, content, errors };
2929
2644
  }
2930
2645
 
2931
- // The events a bundle carries, unwrapping any evidence-export wrapper(s) to
2932
- // the inline source bundle — mirroring how verifyEvidenceBundle recurses into
2933
- // `source_bundle`, so the events line up with its results.
2934
- // `undefined` when the bundle carries no events this reader understands, which
2935
- // a caller must distinguish from a body that has events and no links.
2936
- //
2937
- // BOTH event-carrying bodies, not just anchor-trace (proof#73): §5.3's
2938
- // `sanning.stamp.evidence/v2` is the hand-over pack, and it presents the same
2939
- // event shape.
2940
- //
2941
- // Exported for `chain.ts`, which needs the same descent and must not derive it
2942
- // again: two answers to "which events is this bundle actually about" inside one
2943
- // kernel is two implementations of one rule, and the one that drifted would be
2944
- // the one nobody was looking at. Deliberately NOT re-exported from `index.ts` —
2945
- // same posture as `side-content.ts`, internal and shared.
2646
+ // The events a bundle carries, through any export wrappers to the inline
2647
+ // source, as `verifyEvidenceBundle` recurses, so they line up with its results.
2648
+ // Both event-carrying bodies (anchor trace and §5.3 stamp evidence). `undefined`
2649
+ // when the bundle carries no events this reader understands. Exported for
2650
+ // `chain.ts`, so there is one answer to which events a bundle is about; not
2651
+ // re-exported from `index.ts`.
2946
2652
  export function resolveTraceEvents(bundle: unknown): TraceEvent[] | undefined {
2947
2653
  let cur: unknown = bundle;
2948
2654
  for (let depth = 0; depth < MAX_EXPORT_UNWRAP; depth++) {
2949
2655
  if (cur === null || typeof cur !== "object" || Array.isArray(cur)) return undefined;
2950
2656
  const b = cur as EvidenceBundle;
2951
2657
  const bodyType = typeof b.body_type === "string" ? b.body_type : null;
2952
- // Both event-carrying bodies, not just anchor-trace (proof#73). §5.3's
2953
- // `sanning.stamp.evidence/v2` presents the SAME event shape (a
2954
- // producer-signed envelope, optional `event_bytes`, an inclusion proof),
2955
- // and it is the HAND-OVER PACK: the only artifact an auditor is ever
2956
- // handed. Dispatching on anchor-trace alone meant `readChains` reported
2957
- // zero chains on every pack, however well-formed the links inside its
2958
- // records were, so "is it in order?" read as NOT CHECKED on the one shape
2959
- // that matters. The writers have been emitting those links the whole time.
2960
2658
  if (
2961
2659
  bodyType === ANCHOR_TRACE_BODY_TYPE ||
2962
2660
  bodyType === STAMP_EVIDENCE_BODY_TYPE
@@ -2973,26 +2671,20 @@ export function resolveTraceEvents(bundle: unknown): TraceEvent[] | undefined {
2973
2671
  return undefined;
2974
2672
  }
2975
2673
 
2976
- // ===========================================================================
2977
- // Attested-evidence-export verification (evidence-export.md §5, steps 3–9).
2978
- // Reached from verifyEvidenceBundle AFTER the wrapper (Ed25519 issuer signature
2979
- // + body_hash) has already verified (steps 1–2). This composes the existing
2980
- // evidence path (recompute the inline source verdict) with the one new
2981
- // primitive (embedded RSA-PSS operator attestations, verifyRsaPssSha256).
2982
- // ===========================================================================
2674
+ // Attested-evidence-export verification (evidence-export.md §5, steps 3 to 9),
2675
+ // reached after the wrapper verified. Recomputes the inline source verdict and
2676
+ // checks the embedded RSA-PSS operator attestations.
2983
2677
  async function verifyExportBody(
2984
2678
  b: EvidenceBundle,
2985
2679
  specVersionOk: boolean,
2986
- assertedStatus: string | null,
2987
2680
  errors: string[],
2988
2681
  options: VerifyEvidenceOptions,
2989
2682
  ): Promise<EvidenceBundleResult> {
2990
- // Malformed-as-an-export (§5 exit 2): a required field is missing or a key is
2991
- // unparseable — the wrapper verified, but no verdict can be rendered. The
2992
- // wrapper fields stay true (they DID verify) for any consumer that inspects
2993
- // them; the CLI's malformed branch shows the errors and exits 2.
2683
+ // The wrapper verified but no verdict can be rendered: unreadable as an export.
2684
+ // The wrapper fields stay true, because they did verify.
2994
2685
  const exportMalformed = (error: string): EvidenceBundleResult => ({
2995
- status: "malformed",
2686
+ status: "incomplete",
2687
+ unreadable: true,
2996
2688
  specVersionOk,
2997
2689
  bodyHashOk: true,
2998
2690
  signatureOk: true,
@@ -3000,7 +2692,6 @@ async function verifyExportBody(
3000
2692
  onChainChecked: false,
3001
2693
  checkpoints: [],
3002
2694
  events: [],
3003
- assertedStatus,
3004
2695
  errors: [...errors, error],
3005
2696
  });
3006
2697
 
@@ -3022,14 +2713,13 @@ async function verifyExportBody(
3022
2713
  return exportMalformed("export body missing kernel_verdict");
3023
2714
  }
3024
2715
 
3025
- // --- Step 3: source-bundle linkage ----------------------------------------
2716
+ // Step 3: source-bundle linkage.
3026
2717
  const inline = body.source_bundle;
3027
2718
  if (inline === undefined || inline === null) {
3028
- // source_bundle_ref (out-of-line) is not verifiable on a network-isolated
3029
- // machine unless the referenced bytes are co-delivered — the source-
3030
- // dependent checks are UNDETERMINED (exit 3), NOT a failure (§5 step 3).
2719
+ // An out-of-line source is not verifiable offline unless co-delivered: the
2720
+ // source-dependent checks are not checked, never failed.
3031
2721
  return {
3032
- status: "partial",
2722
+ status: "incomplete",
3033
2723
  specVersionOk,
3034
2724
  bodyHashOk: true,
3035
2725
  signatureOk: true,
@@ -3037,11 +2727,10 @@ async function verifyExportBody(
3037
2727
  onChainChecked: false,
3038
2728
  checkpoints: [],
3039
2729
  events: [],
3040
- assertedStatus,
3041
2730
  errors: [
3042
2731
  ...errors,
3043
- "source_bundle is not inline (source_bundle_ref) — bytes unavailable offline; " +
3044
- "source-dependent checks undetermined",
2732
+ "source_bundle is not inline (source_bundle_ref): bytes unavailable offline; " +
2733
+ "source-dependent checks not checked",
3045
2734
  ],
3046
2735
  };
3047
2736
  }
@@ -3059,86 +2748,70 @@ async function verifyExportBody(
3059
2748
  errors.push(`source_bundle canonicalization failed${thrownDetail(e)}`);
3060
2749
  }
3061
2750
 
3062
- // --- Step 4: recompute the source verdict ---------------------------------
3063
- // The existing evidence path over the inline source bundle — same primitives
3064
- // (Ed25519 envelopes, payload binding, RFC 9162 inclusion, merkle roots,
3065
- // content_ok, and the optional per-gateway on-chain re-fetch, §4.2 / step 7).
2751
+ // Step 4: recompute the source verdict, including the optional re-fetch.
3066
2752
  const sourceResult = await verifyExportSource(inline, options);
3067
2753
 
3068
- // Attestation data_hash binding (§5 6c).
2754
+ // For the attestation data_hash binding (§5 step 6c).
3069
2755
  const sourceCheckpoints = await indexSourceCheckpoints(inline);
3070
2756
 
3071
- // --- Step 6: embedded attestation records ---------------------------------
2757
+ // Step 6: embedded attestation records.
3072
2758
  const attestations: AttestationResult[] = [];
3073
2759
  for (const rec of body.attestations) {
3074
2760
  const r = await verifyAttestation(rec, sourceCheckpoints);
3075
2761
  if (r.malformed !== undefined) return exportMalformed(r.malformed);
2762
+ if (typeof r.result.fieldError === "string") errors.push(r.result.fieldError);
3076
2763
  attestations.push(r.result);
3077
2764
  }
3078
2765
 
3079
- // --- Build the recomputed §4 verdict object -------------------------------
3080
2766
  const verdict = buildVerdictObject(sourceResult, attestations);
3081
2767
  if (typeof b.generated_at === "string") verdict.as_of = b.generated_at;
3082
2768
 
3083
- // --- Step 5: verdict agreement (DETERMINISTIC dimensions only) ------------
3084
- // The cached body.kernel_verdict is recompute-don't-trust (principle 3). But
3085
- // agreement compares ONLY the deterministic, offline-recomputable dimensions:
3086
- // per-event signature/payload-binding/inclusion/content, per-checkpoint
3087
- // merkle roots, and per-attestation signature/operator-address/data_hash
3088
- // binding. The on-chain per-gateway dimension is environment/time-dependent —
3089
- // the issuer observed it online at compose time; an offline verifier
3090
- // legitimately sees `null` — so it is INFORMATIONAL and MUST NOT by itself
3091
- // trigger an exit-1 tamper verdict (the verifier's own on-chain outcomes fold
3092
- // in at step 7 only if it re-fetches). Advisory rollups (status / counts /
3093
- // summary / as_of / custody_chain) are derived, not compared. See the §5
3094
- // step-5 erratum flagged in the slice report.
2769
+ // Step 5: the cached `kernel_verdict` is never trusted, and agreement compares
2770
+ // only the deterministic, offline-recomputable dimensions. The on-chain
2771
+ // dimension depends on when and where it was observed, so it must not by
2772
+ // itself read as tamper. Derived rollups are not compared.
3095
2773
  let verdictAgreementOk = false;
3096
2774
  try {
3097
2775
  const cached = projectVerdictForAgreement(body.kernel_verdict);
3098
2776
  const fresh = projectVerdictForAgreement(verdict);
3099
- toleratePreA20Content(cached, fresh, sourceEventsGoverned(inline));
2777
+ tolerateUnboundMessageContent(cached, fresh, sourceEventsGoverned(inline));
3100
2778
  const cachedProjection = jcs(cached);
3101
2779
  const freshProjection = jcs(fresh);
3102
2780
  verdictAgreementOk = cachedProjection === freshProjection;
3103
2781
  if (!verdictAgreementOk) {
3104
2782
  errors.push(
3105
2783
  "recomputed verdict disagrees with the cached kernel_verdict " +
3106
- "(deterministic dimensions) — recompute-don't-trust",
2784
+ "(deterministic dimensions); recompute-don't-trust",
3107
2785
  );
3108
2786
  }
3109
2787
  } catch (e) {
3110
2788
  errors.push(`verdict agreement comparison failed${thrownDetail(e)}`);
3111
2789
  }
3112
2790
 
3113
- // --- Step 7 already folded in by the source recompute ---------------------
3114
- // (verifyEvidenceBundle merged per-gateway outcomes into sourceResult.status:
3115
- // a checkpoint `mismatch` made it "failed"; an all-`unreachable` checkpoint
3116
- // made it "partial" + an unreachable note → exit 3.)
2791
+ // Step 7 is already folded into `sourceResult.status` by the source recompute.
3117
2792
 
3118
- // Propagate the source recompute's notes so the auditor — and the CLI's
3119
- // exit-3 detection (which keys off "unreachable") — see them.
2793
+ // The CLI's exit-3 detection keys off "unreachable" in these notes.
3120
2794
  for (const e of sourceResult.errors) errors.push(`source: ${e}`);
3121
2795
 
3122
- // --- Step 9: export rollup + exit -----------------------------------------
2796
+ // Step 9: export rollup.
3123
2797
  const anyAttestationFailed = attestations.some((a) => !a.ok);
3124
2798
  let status: EvidenceStatus;
3125
2799
  if (
3126
2800
  !sourceLinkageOk ||
3127
2801
  !verdictAgreementOk ||
3128
2802
  sourceResult.status === "failed" ||
3129
- sourceResult.status === "malformed" ||
2803
+ sourceResult.unreadable === true ||
3130
2804
  anyAttestationFailed
3131
2805
  ) {
3132
2806
  status = "failed";
3133
- } else if (sourceResult.status === "partial") {
3134
- // Undetermined source (a withheld record, or an unreachable gateway):
3135
- // exit 3 either way since 0.13.0. Never a failure.
3136
- status = "partial";
2807
+ } else if (sourceResult.status === "incomplete") {
2808
+ // A source not fully checked (a withheld record). Never a failure.
2809
+ status = "incomplete";
3137
2810
  } else {
3138
2811
  status = "verified";
3139
2812
  }
3140
2813
 
3141
- // The DISPLAYED verdict reflects the full recompute (incl. attestations).
2814
+ // The displayed verdict reflects the full recompute, attestations included.
3142
2815
  verdict.status = status;
3143
2816
  verdict.summary = summarizeExport(status, sourceResult, attestations);
3144
2817
 
@@ -3149,11 +2822,9 @@ async function verifyExportBody(
3149
2822
  signatureOk: true,
3150
2823
  bodyType: EXPORT_BODY_TYPE,
3151
2824
  onChainChecked: sourceResult.onChainChecked,
3152
- // The top-level per-checkpoint / per-event results ARE the recomputed source
3153
- // results, so the CLI's existing rendering shows them unchanged.
2825
+ // The recomputed source results, so the CLI renders them unchanged.
3154
2826
  checkpoints: sourceResult.checkpoints,
3155
2827
  events: sourceResult.events,
3156
- assertedStatus,
3157
2828
  export: {
3158
2829
  sourceLinkageOk,
3159
2830
  sourceStatus: sourceResult.status,
@@ -3161,33 +2832,26 @@ async function verifyExportBody(
3161
2832
  attestations,
3162
2833
  verdict,
3163
2834
  status,
2835
+ // A source whose own wrapper time breaks its format is a source that does not follow
2836
+ // its format, as one that cannot be read is.
2837
+ sourceUnreadable:
2838
+ (sourceResult.unreadable === true || sourceResult.generatedAtMalformed === true) && sourceLinkageOk,
3164
2839
  },
3165
2840
  errors,
3166
2841
  };
3167
2842
  }
3168
2843
 
3169
- // ===========================================================================
3170
- // Compose-side verdict recompute (evidence-export.md §5 steps 4 + 6, minus the
3171
- // wrapper). The `proof export` composer must embed a cached `kernel_verdict`
3172
- // (§4) whose deterministic dimensions BYTE-AGREE with what verifyExportBody will
3173
- // later recompute over the same inline source bundle + attestation records (§5
3174
- // step 5). Rather than reimplement that recompute, this reuses the EXACT verify
3175
- // primitives verifyExportBody uses — verifyEvidenceBundle over the source,
3176
- // verifyAttestation per record, then buildVerdictObject — so the composed cache
3177
- // and the verifier's recompute cannot drift. Offline/pure: no gateways, so the
3178
- // on-chain dimension is null (excluded from agreement anyway, §5 step 5).
3179
- //
3180
- // Throws on a malformed attestation record (unparseable RSA key, wrong
3181
- // signature_alg, missing field) — a composer INPUT error the caller surfaces,
3182
- // mirroring verifyExportBody's malformed→exit-2 for the same conditions.
3183
- // ===========================================================================
2844
+ // Compose-side verdict recompute (evidence-export.md §5 steps 4 and 6). The
2845
+ // `proof export` composer embeds a cached `kernel_verdict` that must agree with
2846
+ // what `verifyExportBody` later recomputes, so it uses the same primitives.
2847
+ // Offline: the on-chain dimension is null. Throws on a malformed attestation
2848
+ // record, a composer input error.
3184
2849
  export interface ComposeVerdictResult {
3185
- // The §4 verdict object to embed as body.kernel_verdict (status/summary/as_of
3186
- // finalized exactly as verifyExportBody would render them).
2850
+ // The §4 verdict to embed as `body.kernel_verdict`, finalized as
2851
+ // `verifyExportBody` renders it.
3187
2852
  verdict: VerdictObject;
3188
- // The recomputed inline-source-bundle status (verifyEvidenceBundle).
3189
2853
  sourceStatus: EvidenceStatus;
3190
- // The export rollup status (§4.5 / §5 step 9) — verified | partial | failed.
2854
+ // The export rollup (§5 step 9).
3191
2855
  status: EvidenceStatus;
3192
2856
  // Per-attestation results (each already bound to a source checkpoint).
3193
2857
  attestations: AttestationResult[];
@@ -3201,15 +2865,15 @@ export async function recomputeExportVerdict(
3201
2865
  ): Promise<ComposeVerdictResult> {
3202
2866
  const sourceResult = await verifyExportSource(sourceBundle, options);
3203
2867
 
3204
- // Attestation data_hash binding (§5 6c) — the SAME index construction as
3205
- // verifyExportBody, deliberately shared rather than restated.
2868
+ // The same index `verifyExportBody` builds (§5 step 6c).
3206
2869
  const sourceCheckpoints = await indexSourceCheckpoints(sourceBundle);
3207
2870
 
3208
2871
  const attestations: AttestationResult[] = [];
3209
2872
  for (const rec of attestationRecords) {
3210
2873
  const r = await verifyAttestation(rec, sourceCheckpoints);
3211
- if (r.malformed !== undefined) {
3212
- throw new Error(`attestation record is malformed: ${r.malformed}`);
2874
+ const malformedField = r.malformed ?? r.result.fieldError ?? undefined;
2875
+ if (malformedField !== undefined) {
2876
+ throw new Error(`attestation record is malformed: ${malformedField}`);
3213
2877
  }
3214
2878
  attestations.push(r.result);
3215
2879
  }
@@ -3217,20 +2881,18 @@ export async function recomputeExportVerdict(
3217
2881
  const verdict = buildVerdictObject(sourceResult, attestations);
3218
2882
  verdict.as_of = generatedAt;
3219
2883
 
3220
- // Export rollup (§5 step 9). Source linkage and verdict agreement are always
3221
- // satisfied at compose time (the composer computes the source hash itself and
3222
- // there is no prior cached verdict to disagree with), so the only failure
3223
- // inputs here are the recomputed source status and any failed attestation.
2884
+ // Linkage and agreement always hold at compose time, so only the source
2885
+ // status and the attestations can fail the rollup.
3224
2886
  const anyAttestationFailed = attestations.some((a) => !a.ok);
3225
2887
  let status: EvidenceStatus;
3226
2888
  if (
3227
2889
  sourceResult.status === "failed" ||
3228
- sourceResult.status === "malformed" ||
2890
+ sourceResult.unreadable === true ||
3229
2891
  anyAttestationFailed
3230
2892
  ) {
3231
2893
  status = "failed";
3232
- } else if (sourceResult.status === "partial") {
3233
- status = "partial";
2894
+ } else if (sourceResult.status === "incomplete") {
2895
+ status = "incomplete";
3234
2896
  } else {
3235
2897
  status = "verified";
3236
2898
  }
@@ -3240,19 +2902,10 @@ export async function recomputeExportVerdict(
3240
2902
  return { verdict, sourceStatus: sourceResult.status, status, attestations };
3241
2903
  }
3242
2904
 
3243
- // Verify one embedded attestation record (§5 step 6a–d). Returns `malformed`
3244
- // (→ exit 2) for an unparseable RSA key / signature hex or a missing required
3245
- // field; otherwise a per-dimension AttestationResult whose `ok` gates on the
3246
- // three binding checks (sig / operator-address / data_hash). subject_ref is
3247
- // undetermined-tolerant and never gates.
3248
- // The attestation payload fields evidence-export.md §3.1 marks Required, with
3249
- // the type it gives each. A record whose payload lacks one, or carries another
3250
- // type (null included), is malformed: §5 step 9 and §4.5 make "a missing
3251
- // required field" an export the verifier cannot render (exit 2). §5 step 6
3252
- // gates a WELL-FORMED record on signature, operator and data_hash only; these
3253
- // checks decide whether the record is well-formed at all. `level` is the §3.1
3254
- // enum 1 | 2 | 3. The integers are non-negative and within 2**53 - 1 (the
3255
- // §3.1 clarification).
2905
+ // The payload fields evidence-export.md section 3.1 requires, with their types. Read only
2906
+ // under a signature that verifies: a record then missing one, or carrying another type
2907
+ // (null included), is one the issuer signed that does not follow its format (section 4.5,
2908
+ // `follows_rules` disproven). `level` is 1, 2 or 3; integers are non-negative safe integers.
3256
2909
  const PAYLOAD_REQUIRED: readonly (readonly [string, "string" | "boolean" | "integer" | "level"])[] = [
3257
2910
  ["attested_at", "string"],
3258
2911
  ["tx_id", "string"],
@@ -3279,7 +2932,6 @@ function payloadFieldError(payload: Record<string, unknown>): string | null {
3279
2932
  ok = typeof v === "boolean";
3280
2933
  want = "a boolean";
3281
2934
  } else if (kind === "integer") {
3282
- // §3.1 (clarified): a non-negative integer no larger than 2**53 - 1.
3283
2935
  ok = typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
3284
2936
  want = "a non-negative integer";
3285
2937
  } else {
@@ -3288,13 +2940,17 @@ function payloadFieldError(payload: Record<string, unknown>): string | null {
3288
2940
  }
3289
2941
  if (!ok) return `attestation payload field ${JSON.stringify(name)} is missing or not ${want}`;
3290
2942
  if (name === "attested_at" && !isRfc3339UtcInstant(v)) {
3291
- // evidence-export §3.1 (v3.4): the form `signed_at` pins.
2943
+ // The form `signed_at` pins (evidence-export.md §3.1).
3292
2944
  return 'attestation payload field "attested_at" is not an RFC 3339 UTC date-time with Z naming a real date and time';
3293
2945
  }
3294
2946
  }
3295
2947
  return null;
3296
2948
  }
3297
2949
 
2950
+ // Verify one embedded attestation record (section 5 step 6). Returns `malformed` (exit 2)
2951
+ // where the signature cannot be checked (no payload, an unparseable key or signature);
2952
+ // otherwise `ok` gates on the signature, then the payload fields under it (`fieldError`),
2953
+ // then the operator address and data_hash. `subject_ref` never gates.
3298
2954
  async function verifyAttestation(
3299
2955
  rec: AttestationRecord,
3300
2956
  sourceCheckpoints: Map<string, AnyTraceCheckpoint>,
@@ -3341,17 +2997,14 @@ async function verifyAttestation(
3341
2997
  if (typeof rec.signature !== "string") {
3342
2998
  return { result, malformed: "attestation record missing signature" };
3343
2999
  }
3344
- const fieldError = payloadFieldError(payload as unknown as Record<string, unknown>);
3345
- if (fieldError !== null) return { result, malformed: fieldError };
3346
3000
  const rsaKey = { kty: "RSA" as const, n: pub.n, e: pub.e };
3347
3001
 
3348
3002
  result.operator = typeof payload.operator === "string" ? payload.operator : "";
3349
3003
  result.gateway = typeof payload.gateway === "string" ? payload.gateway : null;
3350
- result.level = typeof payload.level === "number" ? payload.level : null;
3004
+ result.level = safeInteger(payload.level);
3351
3005
 
3352
- // (a) RSA-PSS-SHA-256 signature over JCS(payload). A malformed sig hex or
3353
- // unimportable key THROWS (malformed → exit 2); a well-formed key that simply
3354
- // fails to verify returns false (→ exit 1).
3006
+ // (a) RSA-PSS-SHA-256 over JCS(payload). Malformed hex or an unimportable key
3007
+ // is malformed (exit 2); a signature that does not verify is false (exit 1).
3355
3008
  let payloadBytes: Uint8Array;
3356
3009
  try {
3357
3010
  payloadBytes = utf8(jcs(payload));
@@ -3362,17 +3015,17 @@ async function verifyAttestation(
3362
3015
  result.signatureOk = await verifyRsaPssSha256(payloadBytes, rec.signature, rsaKey);
3363
3016
  if (!result.signatureOk) errs.push("attestation RSA-PSS signature verification failed");
3364
3017
  } catch {
3365
- // A FIXED sentence, not the library's. This kernel throws
3366
- // `verifyRsaPssSha256: malformed signature hex: ...` and the Python kernel
3367
- // raises `malformed signature hex: ...`, so quoting either one split the
3368
- // two kernels on a finding a holder can put in an export body it signs,
3369
- // and an export pack's findings reach a case reading through `reasons`
3370
- // (gate 4 on proof#140, finding 1). Callers of `verifyRsaPssSha256` still
3371
- // get this kernel's own words.
3018
+ // A fixed sentence, not the library's, so both kernels report one finding.
3372
3019
  return { result, malformed: "attestation signature or key is malformed" };
3373
3020
  }
3021
+ // The fields are read only under a signature that holds: over bytes the issuer did not
3022
+ // sign, a bad field says nothing the failed signature does not.
3023
+ if (result.signatureOk) {
3024
+ result.fieldError = payloadFieldError(payload as unknown as Record<string, unknown>);
3025
+ if (result.fieldError !== null) errs.push(result.fieldError);
3026
+ }
3374
3027
 
3375
- // (b) operator-address binding: base64url(SHA-256(modulus)) == payload.operator.
3028
+ // (b) base64url(SHA-256(modulus)) == payload.operator.
3376
3029
  try {
3377
3030
  const derived = await deriveOperatorAddress(pub.n);
3378
3031
  result.operatorAddressBound = result.operator !== "" && derived === result.operator;
@@ -3385,10 +3038,8 @@ async function verifyAttestation(
3385
3038
  return { result, malformed: `attestation operator key${thrownDetail(e)}` };
3386
3039
  }
3387
3040
 
3388
- // (c) checkpoint resolution + data_hash binding. The checkpoint tx data IS the
3389
- // uploaded JCS(envelope) bytes (the same bytes the on-chain re-fetch compares
3390
- // against, §4.2), so the checkpoint's committed content hash is
3391
- // SHA-256(JCS(checkpoint.envelope)).
3041
+ // (c) data_hash binding. The checkpoint's witnessed data is its uploaded
3042
+ // JCS(envelope), so the committed hash is SHA-256(JCS(checkpoint.envelope)).
3392
3043
  const cp = sourceCheckpoints.get(result.checkpointRef);
3393
3044
  result.checkpointResolved = cp !== undefined;
3394
3045
  if (!cp) {
@@ -3411,21 +3062,20 @@ async function verifyAttestation(
3411
3062
  }
3412
3063
  }
3413
3064
 
3414
- // (d) subject_ref (§3.2) — well-formedness only (no side-input subject channel
3415
- // in v1); undetermined (null), never a hard failure.
3065
+ // (d) subject_ref (§3.2): well-formedness only; null, never a failure.
3416
3066
  result.subjectRefOk = evaluateSubjectRef(payload.subject_ref, errs);
3417
3067
 
3418
3068
  result.ok =
3419
3069
  result.signatureOk &&
3070
+ (result.fieldError ?? null) === null &&
3420
3071
  result.operatorAddressBound &&
3421
3072
  result.dataHashBound &&
3422
3073
  result.checkpointResolved;
3423
3074
  return { result };
3424
3075
  }
3425
3076
 
3426
- // subject_ref (§3.2): absent → null (unbound). Present + well-formed but no
3427
- // side-input subject supplied → null (undetermined, mirrors disclosed-content).
3428
- // Present + malformed → null + a surfaced note (never gates the attestation).
3077
+ // subject_ref (§3.2): null when absent, when well-formed with no side input to
3078
+ // compare against, and when malformed (with a note). Never gates.
3429
3079
  function evaluateSubjectRef(subjectRef: unknown, errs: string[]): boolean | null {
3430
3080
  if (subjectRef === undefined || subjectRef === null) return null;
3431
3081
  if (typeof subjectRef !== "object" || Array.isArray(subjectRef)) {
@@ -3439,13 +3089,11 @@ function evaluateSubjectRef(subjectRef: unknown, errs: string[]): boolean | null
3439
3089
  errs.push("subject_ref is present but not well-formed ({hash: sha256-hex, type: token})");
3440
3090
  return null;
3441
3091
  }
3442
- return null; // well-formed, no side input → undetermined
3092
+ return null;
3443
3093
  }
3444
3094
 
3445
- // Build the §4 verdict object (snake_case, spec-canonical) from the recomputed
3446
- // source result + the attestation results. This is the recomputed copy the
3447
- // renderer/verify-API display; it is compared (over its deterministic subset)
3448
- // to the cached body.kernel_verdict in step 5.
3095
+ // Build the §4 verdict object from the recomputed source result and the
3096
+ // attestations: what surfaces display, compared with the cached copy in step 5.
3449
3097
  function buildVerdictObject(
3450
3098
  source: EvidenceBundleResult,
3451
3099
  attestations: AttestationResult[],
@@ -3496,7 +3144,7 @@ function buildVerdictObject(
3496
3144
 
3497
3145
  return {
3498
3146
  schema_version: "sanning.evidence.verdict/v1",
3499
- status: source.status === "malformed" ? "failed" : source.status,
3147
+ status: source.unreadable === true ? "failed" : source.status,
3500
3148
  counts: { verified, failed, undetermined },
3501
3149
  events,
3502
3150
  checkpoints,
@@ -3508,8 +3156,7 @@ function toVerdictOnChain(o: OnChainResult): VerdictOnChain {
3508
3156
  return {
3509
3157
  rollup: o.rollup,
3510
3158
  on_chain_ok: o.onChainOk,
3511
- // The kernel emits {gateway, outcome}; block_height is issuer-side
3512
- // enrichment (optional in the §4.4 schema) the offline verifier does not
3159
+ // `block_height` is issuer-side enrichment the offline verifier does not
3513
3160
  // recompute.
3514
3161
  per_gateway: o.perGateway.map((g) => ({ gateway: g.gateway, outcome: g.outcome })),
3515
3162
  };
@@ -3517,16 +3164,13 @@ function toVerdictOnChain(o: OnChainResult): VerdictOnChain {
3517
3164
 
3518
3165
  function eventVerdictStatus(e: EventResult): string {
3519
3166
  if (!e.ok) return "failed";
3520
- if (e.payloadBindingOk === null) return "partial"; // withheld record — undetermined
3167
+ if (e.payloadBindingOk === null) return "incomplete"; // withheld record
3521
3168
  return "verified";
3522
3169
  }
3523
3170
 
3524
- // The §5 step-5 agreement projection: strip every environment/time-dependent
3525
- // and derived dimension, leaving only what an offline verifier deterministically
3526
- // recomputes. Tolerant of a hostile/garbled cached verdict (optional chaining +
3527
- // nullish normalization) — a structural difference surfaces as disagreement,
3528
- // which is the correct tamper signal. Works on both the cached snake_case JSON
3529
- // and the freshly built VerdictObject.
3171
+ // The §5 step 5 projection: only what an offline verifier deterministically
3172
+ // recomputes. Tolerates a garbled cached verdict: a structural difference
3173
+ // reads as disagreement, which is the right signal.
3530
3174
  function projectVerdictForAgreement(v: unknown): {
3531
3175
  schema_version: string | null;
3532
3176
  events: { content_ok: boolean | null }[];
@@ -3553,7 +3197,7 @@ function projectVerdictForAgreement(v: unknown): {
3553
3197
  return {
3554
3198
  checkpoint_ref: cp.checkpoint_ref ?? null,
3555
3199
  merkle_root_ok: cp.merkle_root_ok ?? null,
3556
- // on_chain DELIBERATELY OMITTED — environment/time-dependent (see step 5).
3200
+ // on_chain omitted: environment and time dependent.
3557
3201
  attestations: atts.map((a) => {
3558
3202
  const at = (a ?? {}) as VerdictAttestation;
3559
3203
  return {
@@ -3569,11 +3213,9 @@ function projectVerdictForAgreement(v: unknown): {
3569
3213
  };
3570
3214
  }
3571
3215
 
3572
- // Per event of an export's inline `sanning.anchor.trace/v3` source, in order:
3573
- // whether `envelope-spec.md` §4.1 governs its record. Decoded the way the
3574
- // content binding decodes it, so an event whose record is withheld, not hex or
3575
- // not JCS is not governed here either. The Python twin is
3576
- // `evidence._source_events_governed`.
3216
+ // Per event of an export's inline source, in order: whether §4.1 governs its
3217
+ // record, decoded as the content binding decodes it. Python's twin is
3218
+ // `_source_events_governed`.
3577
3219
  function sourceEventsGoverned(source: unknown): boolean[] {
3578
3220
  if (source === null || typeof source !== "object" || Array.isArray(source)) return [];
3579
3221
  const s = source as { body_type?: unknown; body?: unknown };
@@ -3605,17 +3247,13 @@ function sourceEventsGoverned(source: unknown): boolean[] {
3605
3247
  });
3606
3248
  }
3607
3249
 
3608
- // The one difference §5 step 5 does not read as a disagreement
3609
- // (evidence-export.md, evidence-bundle.md v1.19, A20): a cached `content_ok` of
3610
- // `null` against a recomputed `true`, on an event whose record §4.1 governs. A
3611
- // kernel before A20 could not bind a hand-off record's disclosed message, so an
3612
- // export it composed claims less than the recompute proves, and reading that
3613
- // as tamper failed an honest export. Only that direction: a cached `true`
3614
- // against a recomputed `false` or `null`, `null` against `false`, and any
3615
- // difference on a record outside §4.1 still disagree. Edits `fresh` in place
3616
- // to match `cached` on those events. The Python twin is
3617
- // `evidence._tolerate_pre_a20_content`.
3618
- function toleratePreA20Content(
3250
+ // The one difference §5 step 5 tolerates: a cached `content_ok` of `null`
3251
+ // against a recomputed `true`, on an event whose record §4.1 governs. A composer
3252
+ // that could not bind hand-off messages claims less than the recompute proves,
3253
+ // which is not tamper. Only that direction; every other difference still
3254
+ // disagrees. Edits `fresh` in place. Python's twin is
3255
+ // `_tolerate_unbound_message_content`.
3256
+ function tolerateUnboundMessageContent(
3619
3257
  cached: { events: { content_ok: boolean | null }[] },
3620
3258
  fresh: { events: { content_ok: boolean | null }[] },
3621
3259
  governed: boolean[],
@@ -3645,12 +3283,8 @@ function summarizeExport(
3645
3283
  );
3646
3284
  }
3647
3285
 
3648
- // Re-fetch a checkpoint's raw bytes from EACH gateway and classify every
3649
- // gateway's individual outcome (evidence-export.md §4.2), then roll them up
3650
- // worst-finding-wins. Unlike the pre-§4.2 first-responsive-wins collapse, this
3651
- // records a per-gateway array so a confirm/mismatch/unreachable MIX across
3652
- // gateways is preserved; the derived collapsed `onChainOk` reproduces the old
3653
- // single-boolean exactly for a single gateway.
3286
+ // Re-fetch a checkpoint's bytes from each gateway and record each outcome
3287
+ // (evidence-export.md §4.2), then roll them up worst-finding-wins.
3654
3288
  async function refetchCheckpoint(
3655
3289
  cp: AnyTraceCheckpoint,
3656
3290
  anchorRef: AnchorRef,
@@ -3659,44 +3293,22 @@ async function refetchCheckpoint(
3659
3293
  ): Promise<RefetchOutcome> {
3660
3294
  let expectedHex: string | undefined;
3661
3295
  try {
3662
- // A checkpoint's witnessed bytes are its envelope RE-CANONICALIZED. The
3663
- // bundle carries the envelope as a JSON object, not as bytes, so what was
3664
- // uploaded has to be recomputed from it. See `refetchStamp` for the case
3665
- // where this step is not merely unnecessary but WRONG.
3296
+ // The bundle carries the envelope as an object, so its witnessed bytes are
3297
+ // recomputed as JCS. Stamps differ: see `refetchStamp`.
3666
3298
  expectedHex = toHex(utf8(jcs(cp.envelope)));
3667
3299
  } catch {
3668
- // Cannot canonicalize the checkpoint envelope — every gateway is a mismatch
3669
- // (we can never confirm bytes we cannot compute).
3300
+ // Bytes we cannot compute can never be confirmed: every gateway mismatches.
3670
3301
  expectedHex = undefined;
3671
3302
  }
3672
3303
 
3673
3304
  return refetchAnchored("checkpoint", expectedHex, anchorRef, gateways, fetchImpl);
3674
3305
  }
3675
3306
 
3676
- // Re-fetch a STAMP's witnessed bytes (evidence-bundle.md §5.2.2 / §5.3.2).
3677
- //
3678
- // ── The one step this path must NOT copy from the checkpoint path ──
3679
- //
3680
- // `refetchCheckpoint` recomputes `JCS(envelope)` because a checkpoint's
3681
- // witnessed bytes are not in the bundle — only the object they were derived
3682
- // from is. A stamp is the opposite case: §5.2.2 defines `stamp_bytes` as
3683
- // "lowercase hex of the exact JCS bytes that were handed to the witness", and
3684
- // §5.3.2 repeats it — *"the EXACT bytes that were witnessed, verbatim"*. They
3685
- // are already the bytes, already hex, already lowercase (`isLowerHex` gated
3686
- // them), and `stamp_ref` is their SHA-256.
3687
- //
3688
- // So the expected hex IS `stamp_bytes`, and re-canonicalizing would be a
3689
- // defect, not a redundancy: it would compare the chain against bytes NOBODY
3690
- // SIGNED. On a canonical body the two agree and the bug hides; on a body that
3691
- // is not canonical — the exact case §5.2.2 makes the kernel check for, because
3692
- // the plane replays these bytes as text precisely so a normalizing serializer
3693
- // cannot alter them — a re-canonicalizing verifier reports `mismatch` against
3694
- // a chain that faithfully holds what it was given. A false accusation, from
3695
- // the verifier, about untampered evidence.
3696
- //
3697
- // The bytes handed here are the SAME value `verifyStampEntry` hashed to derive
3698
- // `stamp_ref`, threaded through rather than re-read from the body, so the bytes
3699
- // graded and the bytes compared cannot drift apart.
3307
+ // Re-fetch a stamp's witnessed bytes (§5.2.2, §5.3.2). Unlike a checkpoint,
3308
+ // `stamp_bytes` already are the exact witnessed bytes, so they are compared
3309
+ // verbatim: re-canonicalizing would compare the chain against bytes nobody
3310
+ // signed and accuse a faithful chain of mismatch on a non-canonical body. The
3311
+ // hex is the same value `verifyStampEntry` hashed for `stamp_ref`.
3700
3312
  async function refetchStamp(
3701
3313
  witnessedHex: string,
3702
3314
  anchorRef: AnchorRef,
@@ -3706,11 +3318,8 @@ async function refetchStamp(
3706
3318
  return refetchAnchored("stamp", witnessedHex, anchorRef, gateways, fetchImpl);
3707
3319
  }
3708
3320
 
3709
- // The part both paths share: probe every gateway, roll the outcomes up under
3710
- // the ONE rule (`rollupOnChain`), and phrase the finding. Extracted rather than
3711
- // copied — a second rollup rule is a second place for "mismatch beats confirm
3712
- // beats unreachable" to drift, and the drifted copy is the one nobody reads.
3713
- // The subject only ever selects wording; it never changes a verdict.
3321
+ // Shared by both paths: probe every gateway, roll up with `rollupOnChain`, and
3322
+ // phrase the finding. The subject selects wording only, never a verdict.
3714
3323
  async function refetchAnchored(
3715
3324
  subject: "checkpoint" | "stamp",
3716
3325
  expectedHex: string | undefined,
@@ -3718,14 +3327,9 @@ async function refetchAnchored(
3718
3327
  gateways: string[],
3719
3328
  fetchImpl: typeof fetch,
3720
3329
  ): Promise<RefetchOutcome> {
3721
- // Only a `kind` this kernel has a witness adapter for is re-fetched
3722
- // (evidence-bundle.md §5.1: "where the verifier has an adapter for its
3723
- // `kind`"; §5.1.1: an unrecognised kind MUST NOT fail verification). An `id`
3724
- // of any other kind is not an Arweave id, so whatever a gateway serves at
3725
- // that path says nothing about the witness, and comparing it would turn an
3726
- // unrelated 200 into a false mismatch. No fetch, `onChain: null`, and the
3727
- // run goes `partial`: the caller asked for the witness check and it was not
3728
- // made.
3330
+ // Only a `kind` with a witness adapter is re-fetched (§5.1.1: an unrecognised
3331
+ // kind must not fail verification). No fetch, `onChain: null`, and the run is
3332
+ // `partial`.
3729
3333
  if (!WITNESS_ADAPTER_KINDS.has(anchorRef.kind)) {
3730
3334
  return {
3731
3335
  onChain: null,
@@ -3734,11 +3338,9 @@ async function refetchAnchored(
3734
3338
  error: `no witness adapter for kind '${anchorRef.kind}'; the witness was not checked`,
3735
3339
  };
3736
3340
  }
3737
- // The id becomes a URL path segment. Anything but a 43-character base64url
3738
- // transaction id can leave the gateway's base path once URL parsing
3739
- // normalises it (`../x`, `%2e%2e/x`), or drop part of the path (`a#b`,
3740
- // `a?b`), and the two kernels would then request different URLs. Such an id
3741
- // is never fetched and never failed: the witness is not checked.
3341
+ // The id becomes a URL path segment: anything but a 43-character base64url id
3342
+ // could escape the gateway's base path or make the kernels request different
3343
+ // URLs. Never fetched, never failed: the witness is not checked.
3742
3344
  if (!ARWEAVE_ID_RE.test(anchorRef.id)) {
3743
3345
  return {
3744
3346
  onChain: null,
@@ -3768,9 +3370,7 @@ async function refetchAnchored(
3768
3370
  return { onChain, unavailable: onChain.rollup === "unreachable", unchecked: null, error };
3769
3371
  }
3770
3372
 
3771
- // The `anchor_ref.kind` values this kernel can re-fetch from a gateway
3772
- // (evidence-bundle.md §5.1.1 registers exactly `arweave`). Any other kind is
3773
- // left unchecked by `refetchAnchored`, never probed.
3373
+ // The `anchor_ref.kind` values with a witness adapter (§5.1.1 registers `arweave`).
3774
3374
  const WITNESS_ADAPTER_KINDS: ReadonlySet<string> = new Set(["arweave"]);
3775
3375
 
3776
3376
  // The only `arweave` ids the adapter looks up: a 43-character base64url
@@ -3789,9 +3389,8 @@ interface RefetchOutcome {
3789
3389
  error?: string;
3790
3390
  }
3791
3391
 
3792
- // The bundle-level notes for a run where requested re-fetches were not made.
3793
- // Each note keeps the phrase "could not be re-fetched", which is what both
3794
- // CLIs map to exit 3. The order is fixed so the two kernels emit the same list.
3392
+ // Notes for requested re-fetches that were not made. Each keeps "could not be
3393
+ // re-fetched", which both CLIs map to exit 3. Fixed order across kernels.
3795
3394
  function pushRefetchGapNotes(
3796
3395
  errors: string[],
3797
3396
  subject: "stamps" | "checkpoints",
@@ -3812,8 +3411,7 @@ function pushRefetchGapNotes(
3812
3411
  }
3813
3412
  }
3814
3413
 
3815
- // One gateway's outcome for one checkpoint tx: confirm (bytes match), mismatch
3816
- // (bytes disagree), or unreachable (no response / !ok / network error).
3414
+ // One gateway's outcome: confirm, mismatch, or unreachable.
3817
3415
  async function probeGateway(
3818
3416
  anchorRef: AnchorRef,
3819
3417
  gateway: string,
@@ -3835,24 +3433,21 @@ async function probeGateway(
3835
3433
  return "unreachable";
3836
3434
  }
3837
3435
  if (expectedHex === undefined) return "mismatch";
3838
- // The on-chain bytes are the uploaded envelope bytes (JCS-canonical, per the
3839
- // family upload invariant). Compare structurally too, so a gateway that
3840
- // re-pretty-prints does not spuriously fail — but the canonical re-hash is
3841
- // the load-bearing check.
3436
+ // The on-chain bytes are the uploaded JCS bytes. The structural compare
3437
+ // tolerates a gateway that re-pretty-prints; the canonical match is the
3438
+ // load-bearing check.
3842
3439
  if (toHex(onChainBytes) === expectedHex) return "confirm";
3843
3440
  try {
3844
3441
  const parsed = jcsParse(onChainBytes);
3845
3442
  if (toHex(utf8(jcs(parsed))) === expectedHex) return "confirm";
3846
3443
  } catch {
3847
- // not JSON / not parseable — a genuine mismatch
3444
+ // Not parseable: a genuine mismatch.
3848
3445
  }
3849
3446
  return "mismatch";
3850
3447
  }
3851
3448
 
3852
- // Rollup rule (§4.2): mismatch if ANY gateway is mismatch; else confirm if ANY
3853
- // is confirm; else unreachable. The collapsed `onChainOk`: false on mismatch,
3854
- // true on confirm, null on all-unreachable. An empty gateway list is treated as
3855
- // all-unreachable (no signal).
3449
+ // Rollup (§4.2): mismatch if any gateway mismatched, else confirm if any
3450
+ // confirmed, else unreachable. An empty list is all-unreachable.
3856
3451
  function rollupOnChain(perGateway: PerGatewayOutcome[]): OnChainResult {
3857
3452
  let rollup: OnChainOutcome = "unreachable";
3858
3453
  if (perGateway.some((g) => g.outcome === "mismatch")) rollup = "mismatch";
@@ -3868,33 +3463,22 @@ function getEventId(env: unknown): string {
3868
3463
  return "<unknown>";
3869
3464
  }
3870
3465
 
3871
- // The disclosed bytes offered for ONE trace event (evidence-bundle §5.1),
3872
- // decoded once. Shared by the verifier's content check and the `disclosedContent`
3873
- // ingest API: the bytes a consumer receives are then, by construction, the bytes
3874
- // the verifier hashed against the commitment rather than a second decode of the
3875
- // same field that could drift from it.
3466
+ // The disclosed bytes offered for one event (§5.1), decoded once and shared by
3467
+ // the content check and `disclosedContent`, so a consumer receives exactly the
3468
+ // bytes the verifier hashed.
3876
3469
  interface DisclosedSelection {
3877
3470
  // In-body `events[].content` (the signed disclosure), when it decoded.
3878
3471
  inBody?: Uint8Array;
3879
3472
  // Out-of-band `options.content[eventId]`, when it decoded.
3880
3473
  side?: Uint8Array;
3881
- // A disclosure was OFFERED for this event — in-body or side input — whether or
3882
- // not it decoded. This is what separates "minimal disclosure, nothing to
3883
- // materialize" from "disclosed, but unusable"; conflating those is how a
3884
- // consumer writes an empty log file and believes it succeeded.
3474
+ // A disclosure was offered, in-body or side input, whether or not it
3475
+ // decoded: "nothing disclosed" and "disclosed but unusable" must stay apart.
3885
3476
  offered: boolean;
3886
3477
  // Decode failures, in the verifier's existing wording.
3887
3478
  errors: string[];
3888
3479
  }
3889
3480
 
3890
- // The side-input presence rule lives in `side-content.ts`, stated ONCE.
3891
- //
3892
- // It used to live here as a private `lookupSideContent`, and `grading.ts`
3893
- // carried a second, laxer answer to the same question (`hasEntry`: own +
3894
- // enumerable, nothing more). On one and the same content map the grader said
3895
- // PRESENT while this verifier said nothing was offered, and `gradePack` then
3896
- // returned `incomplete` with ZERO causes. Read `side-content.ts` for the rule
3897
- // and for what each clause of it cost.
3481
+ // The side-input presence rule lives in `./side-content.ts`, stated once.
3898
3482
 
3899
3483
  function selectDisclosedBytes(
3900
3484
  ev: TraceEvent,
@@ -3921,15 +3505,8 @@ function selectDisclosedBytes(
3921
3505
  errors.push(`supplied content for ${eventId} is not hex${thrownDetail(e)}`);
3922
3506
  }
3923
3507
  }
3924
- // `offered` comes from the SAME test that produced the value, never from
3925
- // `value !== undefined` — that is the conflation this whole guard exists to
3926
- // remove, and it was removed from ONE of the two operands. The left one
3927
- // carried it unchanged: an in-body `"content": null` is `!== undefined`, so
3928
- // the event was marked offered while no bytes were selected, and the kernel
3929
- // split against itself — `verifyEvidenceBundle` VERIFIED, `disclosedContent`
3930
- // FAILED, on one bundle. `null` is how an optional field's absence
3931
- // serializes; `inBodyContentOffered` states that once, beside the side-input
3932
- // rule it mirrors.
3508
+ // `offered` comes from the same tests that selected the value, never from
3509
+ // `value !== undefined`: an in-body `null` is an absent optional field.
3933
3510
  return {
3934
3511
  inBody,
3935
3512
  side,