@sanning/proof 0.5.0 → 0.6.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.
package/README.md CHANGED
@@ -92,23 +92,48 @@ A bundle a producer built with disclosure carries the bytes **in the signed body
92
92
  npx @sanning/proof verify trace-bundle.json # in-body content verified inline
93
93
  ```
94
94
 
95
- If the logs travel **alongside** a minimal bundle instead, pass them with `--logs` — a JSON object mapping `event_id` → bytes, where an even-length lowercase-hex string is read as hex and anything else as UTF-8 text:
95
+ If the logs travel **alongside** a minimal bundle instead, pass them with `--logs`. It takes either shape below, and which one you passed is decided by `stat`, never by the extension.
96
+
97
+ **A materialized pack — a DIRECTORY.** `materializeBundle` (in [`@sanning/anchor`](https://github.com/sanning-io/anchor)) writes `bundle.json` + `logs/<event_id>.json`, and each file's **raw bytes are the disclosure**. Point `--logs` at that directory and the pack verifies with nothing of ours in the path:
98
+
99
+ ```bash
100
+ npx @sanning/proof verify pack/bundle.json --logs pack/logs
101
+ ```
102
+
103
+ Nothing is decoded, re-parsed or re-serialised on the way in: the bytes on disk are the bytes hashed against the committed `content_hash` — the same bytes a verify page hashes from the same files. That is the whole point of the directory form. The alternative — reading the directory and hex-encoding it into a map yourself — is a step that has been got wrong twice in shipped code, once by writing the hex *text* into the log files, so `SHA-256(file)` was the hash of the encoding rather than of the bytes and genuine, untampered evidence failed.
104
+
105
+ **A side input — a JSON FILE** mapping `event_id` → bytes, where an even-length lowercase-hex value is read as hex and anything else as UTF-8 text:
96
106
 
97
107
  ```bash
98
108
  npx @sanning/proof verify trace-bundle.json --logs logs.json
99
109
  ```
100
110
 
111
+ Emit hex, not text, into that map: a UTF-8 decode is lossy for a non-text payload, and a log line that happens to *look* like hex would be decoded into something the producer never wrote. `logsSideInput` in `@sanning/anchor` builds the map correctly.
112
+
113
+ The directory form's edge cases, **none of which is a verification failure** — an absent disclosure is not evidence of tampering:
114
+
115
+ | Situation | What happens |
116
+ |---|---|
117
+ | A record with no file | That event's content is **undetermined** (`~`) and reported as undisclosed. Exit code unchanged. |
118
+ | An empty directory | Valid. Discloses nothing, says so, exit code unchanged. |
119
+ | A file whose name is no event id | Read, binds to nothing, and is **named** in a note — a typo'd filename is otherwise indistinguishable from minimal disclosure. |
120
+ | A subdirectory | Skipped, never recursed: the layout is flat. Noted. |
121
+ | Anything not named `<event_id>.json`, or not a regular file | Skipped, noted. |
122
+ | A file we selected but cannot read | **Exit 2.** We were pointed at it, so a permissions or broken-link problem must not read as minimal disclosure. |
123
+
101
124
  Either way each event gains a `contentOk`: **`true`** match · **`false`** mismatch (the event *and* the rollup fail, exit `1`) · **`null`** undetermined — nothing disclosed, or no committed `content_hash` (a withheld record); never a failure, mirroring the payload-binding rule. The CLI prints a `logs ✓/✗/~` mark per event and a `logs: N/M disclosed verified` line.
102
125
 
103
- Programmatically the same side input is `content`, keyed by `event_id` — a `Uint8Array` is used as-is, a `string` is hex:
126
+ Programmatically the same side input is `content`, keyed by `event_id` — a `Uint8Array` is used as-is, a `string` is hex. **Prefer a `Map`:**
104
127
 
105
128
  ```ts
106
129
  const result = await verifyEvidenceBundle(bundle, {
107
- content: { [eventId]: rawLogBytes }, // Uint8Array, or a hex string
130
+ content: new Map([[eventId, rawLogBytes]]), // Uint8Array, or a hex string
108
131
  });
109
132
  result.events[0].contentOk; // true | false | null
110
133
  ```
111
134
 
135
+ A plain object still works and is fully guarded on the way in. But **an `event_id` is producer-chosen**, and on a plain object the names that live on `Object.prototype` are not free: `content["__proto__"] = bytes` does not store anything — it replaces the object's prototype, and the bytes you meant to disclose are lost before the verifier ever sees them. If you are building the map from names you did not choose — a directory listing, a JSON key, an id off the wire — use a `Map` (or `Object.create(null)`). It is also what `disclosedContent` hands back, so its output feeds straight back in.
136
+
112
137
  In-body `events[].content` (signed) takes precedence over the side input; if both are present for an event and disagree, the event fails.
113
138
 
114
139
  ### …and get those raw logs back out (ingest)
@@ -161,6 +186,7 @@ const ok = await verifyInclusion(
161
186
  | `verifyEnvelope(env, optionsOrContentHash?)` | The three load-bearing checks (spec-version registry, payload-hash recompute, Ed25519 over the signed scope) + optional content bind. Pass `{ payloadBytes }` for external-commitment envelopes. Returns `VerificationResult`. |
162
187
  | `verifyEvidenceBundle(bundle, { gateways?, fetchImpl? })` | Verify a signed `sanning.evidence/v1` / `sanning.anchor.trace/v1` bundle: wrapper signature + `body_hash` + every checkpoint + every event's inclusion, offline (optional on-chain re-fetch). Returns `EvidenceBundleResult`. Powers the `npx @sanning/proof verify` CLI. |
163
188
  | `disclosedContent(bundle, { gateways?, content?, fetchImpl? })` | **Ingest:** verify a bundle, then hand back the DECODED raw bytes (`Uint8Array`) of every event whose disclosure bound to its committed `content_hash` — so materializing a received pack never means re-implementing the hex convention. Returns `DisclosedContentResult` (`content` map + per-event `verified` / `not_disclosed` / `undetermined` / `failed`, plus the full `verification`). |
189
+ | `gradePack(result, account, notes)` + `accountDisclosure(bundle, content)` / `completenessNotes(bundle)` / `renderCause(cause)` | **Pack grading:** what a SURFACE may claim about a pack — the bundle plus whatever disclosed bytes travelled with it. Three states, never two: `verified` only when every record's own bytes bound to its own commitment and nothing is missing, unreadable, surplus or omitted; `failed` on a proven mismatch; `incomplete` when a check could not be run. Absent evidence is UNKNOWN, never an accusation. Pure — no I/O; transport stays yours. Conformance table: [`vectors/pack-grading.json`](https://github.com/sanning-io/proof/blob/main/ts/vectors/pack-grading.json), shipped in the tarball. |
164
190
  | `verifyAgentProofBundle(bundle, { gateways?, fetchImpl? })` / `isAgentProofSpec(v)` | Verify the agent producer's `sanning.agent.proof/v1` inclusion bundle. |
165
191
  | `contentHashes(env)` | The content hash(es) an envelope commits to, by event type — the reverse-provenance join keys. |
166
192
  | `specVersionSupported(v)` | Fail-closed accept-check: `sanning.agent/v1` and additive minors (`sanning.agent/v1.<n>`). Unknown majors/profiles are rejected. |
@@ -177,12 +203,13 @@ The primary signature covers `JCS(envelope minus signature minus co_signatures)`
177
203
 
178
204
  ## Why you can trust it (without trusting us)
179
205
 
180
- Three **independent implementations** of this kernel exist — this package, the Python [`sanning-proof`](https://pypi.org/project/sanning-proof/) (PyPI), and the MIT-licensed Go reference, vendored into this repo under [`cross-kernel/vendor-agent/`](https://github.com/sanning-io/proof/tree/main/cross-kernel/vendor-agent) so anyone can build and run it. The TypeScript and Python kernels are conformance-gated **byte-for-byte** in CI against the shared [`test-vectors` corpus](https://github.com/sanning-io/proof/tree/main/test-vectors) (per-file SHA-256 pins; current cut tag `test-vectors-v2.0`), and the [cross-kernel agreement gate](https://github.com/sanning-io/proof/tree/main/cross-kernel) re-runs the corpus plus adversarial negatives through both, asserting identical verdicts on identical bytes. The Go reference runs as a third leg of that same gate, built at the commit its vendoring pins.
206
+ Three **independent implementations** of this kernel exist — this package, the Python [`sanning-proof`](https://pypi.org/project/sanning-proof/) (PyPI), and the MIT-licensed Go reference, vendored into this repo under [`cross-kernel/vendor-agent/`](https://github.com/sanning-io/proof/tree/main/cross-kernel/vendor-agent) so anyone can build and run it. The TypeScript and Python kernels are conformance-gated **byte-for-byte** in CI against the shared [`test-vectors` corpus](https://github.com/sanning-io/proof/tree/main/test-vectors) (per-file SHA-256 pins; current cut tag `test-vectors-v3.0`), and the [cross-kernel agreement gate](https://github.com/sanning-io/proof/tree/main/cross-kernel) re-runs the corpus plus adversarial negatives through both, asserting identical verdicts on identical bytes. The Go reference runs as a third leg of that same gate, built at the commit its vendoring pins.
181
207
 
182
208
  That mutual gate is the point: anyone can verify these provenance records with **no Sanning code in the trust path** — write your own verifier against the [public specs](https://github.com/sanning-io/proof/tree/main/specs) and the corpus will tell you if it's conformant.
183
209
 
184
210
  ## What this package deliberately does NOT do
185
211
 
212
+ - **No I/O, ever.** Including in `gradePack`: it takes an account of what was disclosed, it does not go and read a directory. How a pack reached you is yours.
186
213
  - **Single-envelope scope.** Chain walking (`previous_hash`), checkpoint reconciliation, and gateway/transport logic are consumer-layer concerns composed above the kernel.
187
214
  - **Provenance is history, not endorsement.** A verified envelope proves *these bytes have this signed, anchored history* — never "safe," "approved," or "currently in production."
188
215
  - **Key identity is out-of-band.** The kernel proves the signature matches `env.public_key`; binding that key to a real-world identity is yours to establish.
package/dist/cli.js CHANGED
@@ -13,8 +13,9 @@
13
13
  // 3 gateway-unavailable when an on-chain re-fetch was requested
14
14
  // `payloadHashOk === null` (a withheld record) is semantics-undetermined and
15
15
  // surfaces as a per-event note — it does NOT fail the run.
16
- import { readFile, writeFile } from "node:fs/promises";
16
+ import { readFile, readdir, stat, writeFile } from "node:fs/promises";
17
17
  import { realpathSync } from "node:fs";
18
+ import { join } from "node:path";
18
19
  import { fileURLToPath } from "node:url";
19
20
  import { isAgentProofSpec, verifyAgentProofBundle } from "./agent-proof.js";
20
21
  import { composeExport } from "./compose.js";
@@ -68,7 +69,7 @@ export async function runCli(argv, io) {
68
69
  printUsage(io);
69
70
  return EXIT_MALFORMED;
70
71
  }
71
- // Pull the optional `--logs <file>` flag out of the args; the rest are
72
+ // Pull the optional `--logs <path>` flag out of the args; the rest are
72
73
  // positional (<bundle> [gateways]). --logs feeds disclosed raw-log bytes for
73
74
  // content verification (the evidence-bundle path only).
74
75
  let logsPath;
@@ -78,7 +79,7 @@ export async function runCli(argv, io) {
78
79
  if (a === "--logs") {
79
80
  const next = rest[i + 1];
80
81
  if (next === undefined) {
81
- io.err("verify: --logs requires a file path");
82
+ io.err("verify: --logs requires a path (a logs directory, or a JSON map file)");
82
83
  printUsage(io);
83
84
  return EXIT_MALFORMED;
84
85
  }
@@ -98,34 +99,15 @@ export async function runCli(argv, io) {
98
99
  .split(",")
99
100
  .map((g) => g.trim())
100
101
  .filter((g) => g.length > 0);
101
- let content;
102
+ let logs;
102
103
  if (logsPath !== undefined) {
103
- let logsRaw;
104
- try {
105
- logsRaw = await readFile(logsPath, "utf8");
106
- }
107
- catch (e) {
108
- io.err(`cannot read --logs ${logsPath}: ${e instanceof Error ? e.message : String(e)}`);
109
- return EXIT_MALFORMED;
110
- }
111
- let logsParsed;
112
- try {
113
- logsParsed = JSON.parse(logsRaw);
114
- }
115
- catch (e) {
116
- io.err(`--logs ${logsPath} is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
117
- return EXIT_MALFORMED;
118
- }
119
- if (logsParsed === null || typeof logsParsed !== "object" || Array.isArray(logsParsed)) {
120
- io.err(`--logs ${logsPath} must be a JSON object mapping event_id → disclosed bytes`);
104
+ logs = await loadLogs(logsPath, io);
105
+ if (logs === undefined)
121
106
  return EXIT_MALFORMED;
122
- }
123
- content = {};
124
- for (const [k, v] of Object.entries(logsParsed)) {
125
- if (typeof v === "string")
126
- content[k] = decodeLogValue(v);
127
- }
107
+ for (const n of logs.notes)
108
+ io.err(`note: ${n}`);
128
109
  }
110
+ const content = logs?.content;
129
111
  let raw;
130
112
  try {
131
113
  raw = await readFile(bundlePath, "utf8");
@@ -165,7 +147,12 @@ export async function runCli(argv, io) {
165
147
  ...(content ? { content } : {}),
166
148
  ...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}),
167
149
  });
168
- return reportEvidence(result, bundlePath, gateways, io);
150
+ const exit = reportEvidence(result, bundlePath, gateways, io);
151
+ // Coverage is reported only once the bundle parsed far enough to have events
152
+ // to compare against; over a MALFORMED bundle there is nothing to say.
153
+ if (logs && result.status !== "malformed")
154
+ reportLogsCoverage(logs, result, io);
155
+ return exit;
169
156
  }
170
157
  // `proof bundle <source-bundle.json> --attestations <f> --key <f> [-o <out>]`
171
158
  // (`proof export` still reaches this, undocumented — see the dispatch above.)
@@ -381,10 +368,187 @@ async function readExporterKey(path, io) {
381
368
  }
382
369
  return seed.toLowerCase();
383
370
  }
384
- // The --logs side-input lane decides each disclosed value's encoding: a clean,
371
+ // The suffix the directory form reads, matched exactly — lowercase, which is
372
+ // what `materializeBundle` writes. Anything else is skipped and NAMED rather
373
+ // than guessed at, so a `.JSON` or extensionless file shows up as a note
374
+ // instead of a mysteriously undisclosed event.
375
+ const LOG_SUFFIX = ".json";
376
+ // Which shape was passed is decided by `stat`, never by the extension: a pack's
377
+ // log directory can be called anything, and `logs.json` is a legal directory
378
+ // name. `stat` follows symlinks, so a symlinked logs directory works.
379
+ async function loadLogs(logsPath, io) {
380
+ let isDirectory;
381
+ try {
382
+ isDirectory = (await stat(logsPath)).isDirectory();
383
+ }
384
+ catch (e) {
385
+ io.err(`cannot read --logs ${logsPath}: ${errMsg(e)}`);
386
+ return undefined;
387
+ }
388
+ return isDirectory ? loadLogsDirectory(logsPath, io) : loadLogsMap(logsPath, io);
389
+ }
390
+ // The JSON-map form, unchanged: `{ event_id: <hex | utf8 text> }`.
391
+ async function loadLogsMap(logsPath, io) {
392
+ let logsRaw;
393
+ try {
394
+ logsRaw = await readFile(logsPath, "utf8");
395
+ }
396
+ catch (e) {
397
+ io.err(`cannot read --logs ${logsPath}: ${errMsg(e)}`);
398
+ return undefined;
399
+ }
400
+ let logsParsed;
401
+ try {
402
+ logsParsed = JSON.parse(logsRaw);
403
+ }
404
+ catch (e) {
405
+ io.err(`--logs ${logsPath} is not valid JSON: ${errMsg(e)}`);
406
+ return undefined;
407
+ }
408
+ if (logsParsed === null || typeof logsParsed !== "object" || Array.isArray(logsParsed)) {
409
+ io.err(`--logs ${logsPath} must be a JSON object mapping event_id → disclosed bytes, ` +
410
+ `or a directory of <event_id>${LOG_SUFFIX} files`);
411
+ return undefined;
412
+ }
413
+ const content = new Map();
414
+ const suppliedIds = [];
415
+ for (const [k, v] of Object.entries(logsParsed)) {
416
+ if (typeof v !== "string")
417
+ continue;
418
+ content.set(k, decodeLogValue(v));
419
+ suppliedIds.push(k);
420
+ }
421
+ return { content, suppliedIds, notes: [] };
422
+ }
423
+ // The directory form. Flat, one file per event, `<event_id>.json`.
424
+ //
425
+ // The decisions this pins, none of which is a verification failure — an absent
426
+ // or unusable disclosure is UNDISCLOSED, and absence is not evidence of
427
+ // tampering (evidence-bundle.md §5.1):
428
+ //
429
+ // - a file whose name is not a known event id → loaded, binds to nothing,
430
+ // named in a post-verify note (silence is how a typo'd filename becomes a
431
+ // mysteriously undisclosed event)
432
+ // - an event with no corresponding file → undetermined (`~`), noted
433
+ // - an empty directory → valid; discloses nothing,
434
+ // noted, exit code unchanged
435
+ // - a subdirectory → skipped, never recursed,
436
+ // noted (this is the "you pointed at the pack root, not its logs/" case)
437
+ // - anything not named `<event_id>.json`, and anything that is not a regular
438
+ // file → skipped, noted
439
+ //
440
+ // The one hard stop is a file we SELECTED but cannot read: we were pointed at
441
+ // it, so a permissions error must not read as minimal disclosure. That exits 2.
442
+ async function loadLogsDirectory(dirPath, io) {
443
+ let names;
444
+ try {
445
+ names = await readdir(dirPath);
446
+ }
447
+ catch (e) {
448
+ io.err(`cannot read --logs directory ${dirPath}: ${errMsg(e)}`);
449
+ return undefined;
450
+ }
451
+ const content = new Map();
452
+ const suppliedIds = [];
453
+ const skippedDirs = [];
454
+ const skippedNames = [];
455
+ for (const name of [...names].sort()) {
456
+ const p = join(dirPath, name);
457
+ let isDirectory;
458
+ let isFile;
459
+ try {
460
+ // `stat`, not the dirent kind: a symlinked log file is a log file, and a
461
+ // symlink to a directory is a directory.
462
+ const s = await stat(p);
463
+ isDirectory = s.isDirectory();
464
+ isFile = s.isFile();
465
+ }
466
+ catch (e) {
467
+ io.err(`cannot read --logs entry ${p}: ${errMsg(e)}`);
468
+ return undefined;
469
+ }
470
+ if (isDirectory) {
471
+ skippedDirs.push(name);
472
+ continue;
473
+ }
474
+ // Not a regular file (device, fifo, socket), or not named
475
+ // `<event_id>.json` — including a file named exactly `.json`, whose event
476
+ // id would be the empty string.
477
+ if (!isFile || !name.endsWith(LOG_SUFFIX) || name.length === LOG_SUFFIX.length) {
478
+ skippedNames.push(name);
479
+ continue;
480
+ }
481
+ const eventId = name.slice(0, -LOG_SUFFIX.length);
482
+ try {
483
+ // No encoding argument: bytes, exactly as they sit on disk. Copied out of
484
+ // Buffer's shared pool so the array handed to the verifier owns its own
485
+ // memory rather than a window onto a reused block.
486
+ content.set(eventId, new Uint8Array(await readFile(p)));
487
+ }
488
+ catch (e) {
489
+ io.err(`cannot read --logs file ${p}: ${errMsg(e)}`);
490
+ return undefined;
491
+ }
492
+ suppliedIds.push(eventId);
493
+ }
494
+ const notes = [];
495
+ if (skippedDirs.length > 0) {
496
+ notes.push(`--logs ${dirPath}: skipped ${count(skippedDirs.length, "subdirectory", "subdirectories")} ` +
497
+ `(${sample(skippedDirs)}) — the directory form reads <event_id>${LOG_SUFFIX} files ` +
498
+ `and does not recurse`);
499
+ }
500
+ if (skippedNames.length > 0) {
501
+ notes.push(`--logs ${dirPath}: skipped ${count(skippedNames.length, "entry", "entries")} ` +
502
+ `not named <event_id>${LOG_SUFFIX} (${sample(skippedNames)})`);
503
+ }
504
+ if (suppliedIds.length === 0) {
505
+ notes.push(`--logs ${dirPath} disclosed nothing (no <event_id>${LOG_SUFFIX} files) — every event ` +
506
+ `stays undisclosed, which is undetermined, not failed`);
507
+ }
508
+ return { content, suppliedIds, notes };
509
+ }
510
+ // What the side input covered, reported once the bundle's own event ids are
511
+ // known. Both directions are ADVISORY — neither moves the verdict or the exit
512
+ // code — and both exist because the silent version of each has already cost
513
+ // somebody an afternoon:
514
+ //
515
+ // - an entry matching no event binds to nothing. A typo'd id, or a directory
516
+ // scoped to a different bundle, otherwise looks identical to minimal
517
+ // disclosure.
518
+ // - an event whose content stayed undetermined disclosed no usable bytes.
519
+ // That is UNDISCLOSED, never a failure (evidence-bundle.md §5.1): absence
520
+ // of a disclosure is not evidence of tampering.
521
+ function reportLogsCoverage(logs, result, io) {
522
+ const eventIds = new Set(result.events.map((e) => e.eventId));
523
+ const unmatched = logs.suppliedIds.filter((id) => !eventIds.has(id));
524
+ if (unmatched.length > 0) {
525
+ io.err(`note: --logs supplied ${count(unmatched.length, "entry", "entries")} matching no event ` +
526
+ `in the bundle, ignored: ${sample(unmatched)}`);
527
+ }
528
+ const undetermined = result.events.filter((e) => e.contentOk === null);
529
+ if (undetermined.length > 0) {
530
+ io.err(`note: ${undetermined.length} of ${result.events.length} event(s) have undetermined ` +
531
+ `content — no bytes disclosed, or no committed content_hash to bind them to. ` +
532
+ `Undisclosed, not failed: ${sample(undetermined.map((e) => e.eventId))}`);
533
+ }
534
+ }
535
+ function errMsg(e) {
536
+ return e instanceof Error ? e.message : String(e);
537
+ }
538
+ function count(n, one, many) {
539
+ return `${n} ${n === 1 ? one : many}`;
540
+ }
541
+ // First few names, quoted, with a tail count — enough to recognise the mistake
542
+ // without pasting a whole directory into the terminal.
543
+ function sample(names, max = 3) {
544
+ const shown = names.slice(0, max).map((n) => JSON.stringify(n)).join(", ");
545
+ return names.length > max ? `${shown}, +${names.length - max} more` : shown;
546
+ }
547
+ // The --logs MAP lane decides each disclosed value's encoding: a clean,
385
548
  // even-length, lowercase-hex string is hex bytes; anything else is utf8 text.
386
549
  // (The verifier itself only distinguishes "string ⇒ hex" from "Uint8Array ⇒
387
- // raw bytes" — the human-facing encoding choice is made here.)
550
+ // raw bytes" — the human-facing encoding choice is made here. The DIRECTORY
551
+ // lane never reaches this: its bytes are already bytes.)
388
552
  function decodeLogValue(v) {
389
553
  if (v.length > 0 && v.length % 2 === 0 && /^[0-9a-f]+$/.test(v))
390
554
  return v; // hex passthrough
@@ -394,7 +558,7 @@ function printUsage(io) {
394
558
  io.out("Usage: npx @sanning/proof <command> [args]");
395
559
  io.out("");
396
560
  io.out("Commands:");
397
- io.out(" verify <bundle.json> [gateways] [--logs <logs.json>] Verify a bundle");
561
+ io.out(" verify <bundle.json> [gateways] [--logs <path>] Verify a bundle");
398
562
  io.out(" bundle <source.json> --attestations <f> --key <f> [-o] Create an attested bundle");
399
563
  io.out("");
400
564
  io.out("Verify a sanning.evidence/v1 (sanning.anchor.trace/v1) bundle, an");
@@ -402,10 +566,21 @@ function printUsage(io) {
402
566
  io.out("inclusion bundle, fully offline. Supply a comma-separated gateway list");
403
567
  io.out("to also re-fetch each checkpoint on-chain.");
404
568
  io.out("");
405
- io.out("--logs <logs.json> Bind disclosed raw logs to each event's committed");
406
- io.out(" content_hash. JSON object mapping event_id → bytes");
407
- io.out(" (even-length lowercase hex ⇒ hex, else utf8 text).");
408
- io.out(" In-body events[].content is verified automatically.");
569
+ io.out("--logs <path> Bind disclosed raw logs to each event's committed");
570
+ io.out(" content_hash. Either shape works, and which one you");
571
+ io.out(" passed is decided by stat(), not by the extension:");
572
+ io.out("");
573
+ io.out(" a DIRECTORY of <event_id>.json files — the file's");
574
+ io.out(" raw bytes ARE the content, nothing is decoded.");
575
+ io.out(" This is a materialized pack's logs/ directory:");
576
+ io.out(" proof verify <pack>/bundle.json --logs <pack>/logs");
577
+ io.out("");
578
+ io.out(" a JSON FILE mapping event_id → bytes (an");
579
+ io.out(" even-length lowercase-hex value ⇒ hex, else utf8).");
580
+ io.out("");
581
+ io.out(" In-body events[].content is verified automatically");
582
+ io.out(" and takes precedence. An event with no disclosed");
583
+ io.out(" bytes is undetermined (~), never a failure.");
409
584
  io.out("");
410
585
  io.out("Exit codes: 0 verified · 1 failed · 2 malformed · 3 gateway-unavailable");
411
586
  }
@@ -424,7 +599,7 @@ function reportEvidence(result, path, gateways, io) {
424
599
  io.out(paint(" Checkpoints", DIM));
425
600
  for (const cp of result.checkpoints) {
426
601
  const onChain = cp.onChainOk === null ? "" : ` on-chain ${mark(cp.onChainOk)}`;
427
- io.out(` ${mark(cp.ok)} ${shortTx(cp.txId)} envelope ${mark(cp.envelopeOk)} root ${mark(cp.merkleRootOk)}${onChain}`);
602
+ io.out(` ${mark(cp.ok)} ${shortTx(cp.checkpointRef)} envelope ${mark(cp.envelopeOk)} root ${mark(cp.merkleRootOk)}${onChain}`);
428
603
  for (const e of cp.errors)
429
604
  io.out(paint(` - ${e}`, R));
430
605
  }
package/dist/compose.js CHANGED
@@ -41,7 +41,7 @@ export function attestationSignatureToHex(signature) {
41
41
  }
42
42
  // Normalize one attestation record for embedding: deep-clone (never mutate the
43
43
  // caller's input) and transcode its signature to lowercase hex. Everything else
44
- // — payload, public_key, signature_alg, checkpoint_tx_id — rides verbatim so the
44
+ // — payload, public_key, signature_alg, checkpoint_ref — rides verbatim so the
45
45
  // operator's RSA-PSS signature over JCS(payload) remains valid.
46
46
  function normalizeAttestation(rec) {
47
47
  const cloned = JSON.parse(JSON.stringify(rec));
@@ -1,5 +1,16 @@
1
1
  import type { Envelope } from "./types.js";
2
- export declare const ANCHOR_TRACE_BODY_TYPE = "sanning.anchor.trace/v1";
2
+ export declare const ANCHOR_TRACE_BODY_TYPE = "sanning.anchor.trace/v2";
3
+ /**
4
+ * The frozen pre-`anchor_ref` body (evidence-bundle.md §5.1.3).
5
+ *
6
+ * Accepted PERMANENTLY, not for a deprecation window. Every bundle emitted
7
+ * before 2026-09-03 carries this shape, and those bundles are evidence — a
8
+ * reader that stopped accepting them would destroy the record it exists to
9
+ * defend (DL-30). Nothing new emits it.
10
+ */
11
+ export declare const ANCHOR_TRACE_BODY_TYPE_V1 = "sanning.anchor.trace/v1";
12
+ /** Every anchor-trace body this kernel verifies. Order is newest-first. */
13
+ export declare const ANCHOR_TRACE_BODY_TYPES: readonly ["sanning.anchor.trace/v2", "sanning.anchor.trace/v1"];
3
14
  export declare const EXPORT_BODY_TYPE = "sanning.evidence.export/v1";
4
15
  export interface EvidenceIssuer {
5
16
  kind: string;
@@ -15,7 +26,42 @@ export interface EvidenceVerdict {
15
26
  as_of?: string;
16
27
  [k: string]: unknown;
17
28
  }
29
+ /**
30
+ * Where a commitment was witnessed (evidence-bundle.md §5.1.1).
31
+ *
32
+ * The same three-part shape the control plane indexes (`kind` / one promoted
33
+ * `id` / a typed remainder), so the signed body, the index row and the SDK
34
+ * artifact stop being three vocabularies for one fact.
35
+ *
36
+ * NOT ONE FIELD HERE NAMES A CHAIN. Arweave's chunk proof, data_path, offsets
37
+ * and packing live in `ref`, opaque to every reader above this seam. A field
38
+ * that would need renaming for a second witness belongs in `ref` by definition.
39
+ */
40
+ export interface AnchorRef {
41
+ kind: string;
42
+ id: string;
43
+ ref?: Record<string, unknown>;
44
+ }
18
45
  export interface TraceCheckpoint {
46
+ /**
47
+ * The checkpoint's identity and the join key, DERIVED: hex SHA-256 of the
48
+ * decoded `record_bytes`. Recomputed by the verifier — in v1 this was an
49
+ * asserted label matched against another asserted label, so a producer could
50
+ * relabel a checkpoint and the bundle still verified.
51
+ */
52
+ checkpoint_ref: string;
53
+ /**
54
+ * Where it was witnessed, IF IT HAS BEEN. Optional, and absent is honest:
55
+ * under DL-52 a checkpoint is routinely placed before it is witnessed, and a
56
+ * required field that must be filled dishonestly is a defect in the format.
57
+ */
58
+ anchor_ref?: AnchorRef;
59
+ envelope: Envelope;
60
+ record_bytes: string;
61
+ merkle_root: string;
62
+ }
63
+ /** The frozen `sanning.anchor.trace/v1` checkpoint (§5.1.3). Read, never written. */
64
+ export interface TraceCheckpointV1 {
19
65
  tx_id: string;
20
66
  envelope: Envelope;
21
67
  record_bytes: string;
@@ -26,7 +72,8 @@ export interface TraceInclusion {
26
72
  leaf_index: number;
27
73
  leaf_count: number;
28
74
  audit_path: string[];
29
- checkpoint_tx_id: string;
75
+ /** Resolves against `checkpoints[].checkpoint_ref`. v1 spells this `checkpoint_tx_id`. */
76
+ checkpoint_ref: string;
30
77
  }
31
78
  export interface TraceEvent {
32
79
  envelope: Envelope;
@@ -66,7 +113,25 @@ export interface OnChainResult {
66
113
  perGateway: PerGatewayOutcome[];
67
114
  }
68
115
  export interface CheckpointResult {
69
- txId: string;
116
+ /**
117
+ * The checkpoint's join key AS THE BUNDLE SPELLS IT — the derived
118
+ * `checkpoint_ref` for a v2 body, the legacy `tx_id` for a frozen v1 body.
119
+ * Never re-derived across forms: a v1 bundle's key is its `tx_id`, because
120
+ * that is what its own events resolve against and what an attestation
121
+ * written against it carries.
122
+ */
123
+ checkpointRef: string;
124
+ /**
125
+ * Where this checkpoint was witnessed, normalised to one shape.
126
+ *
127
+ * `null` when a v2 checkpoint carries no `anchor_ref` — placed but not yet
128
+ * witnessed, which is a normal state and NOT a deficiency. For a frozen v1
129
+ * checkpoint this is synthesised as `{ kind: "arweave", id: tx_id }`, which
130
+ * is exactly what v1's spec said the field was ("the Arweave TX the
131
+ * checkpoint envelope was anchored as"). The synthesis is reader-side only
132
+ * and never re-enters a signed artifact or a verdict.
133
+ */
134
+ anchorRef: AnchorRef | null;
70
135
  envelopeOk: boolean;
71
136
  merkleRootOk: boolean;
72
137
  onChainOk: boolean | null;
@@ -98,7 +163,12 @@ export interface EvidenceBundleResult {
98
163
  errors: string[];
99
164
  }
100
165
  export interface AttestationRecord {
101
- checkpoint_tx_id: string;
166
+ /**
167
+ * The source checkpoint this attestation binds to, spelled in the SOURCE
168
+ * bundle's vocabulary: `checkpoint_ref` for a v2 source, the legacy `tx_id`
169
+ * for a frozen v1 one. Not re-derived across forms.
170
+ */
171
+ checkpoint_ref: string;
102
172
  payload: AttestationPayload;
103
173
  signature_alg: string;
104
174
  public_key: {
@@ -158,7 +228,7 @@ export interface VerdictAttestation {
158
228
  subject_ref_ok: boolean | null;
159
229
  }
160
230
  export interface VerdictCheckpoint {
161
- checkpoint_tx_id: string;
231
+ checkpoint_ref: string;
162
232
  merkle_root_ok: boolean;
163
233
  on_chain: VerdictOnChain | null;
164
234
  attestations: VerdictAttestation[];
@@ -179,7 +249,7 @@ export interface VerdictObject {
179
249
  [k: string]: unknown;
180
250
  }
181
251
  export interface AttestationResult {
182
- checkpointTxId: string;
252
+ checkpointRef: string;
183
253
  operator: string;
184
254
  gateway: string | null;
185
255
  signatureOk: boolean;
@@ -201,7 +271,7 @@ export interface ExportResult {
201
271
  }
202
272
  export interface VerifyEvidenceOptions {
203
273
  gateways?: string[];
204
- content?: Record<string, Uint8Array | string>;
274
+ content?: Record<string, Uint8Array | string> | ReadonlyMap<string, Uint8Array | string>;
205
275
  fetchImpl?: typeof fetch;
206
276
  }
207
277
  export declare function verifyEvidenceBundle(bundle: unknown, options?: VerifyEvidenceOptions): Promise<EvidenceBundleResult>;