@sanning/proof 0.4.1 → 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,25 +92,77 @@ 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
 
139
+ ### …and get those raw logs back out (ingest)
140
+
141
+ `events[].content` rides as **lowercase hex** — JSON cannot carry arbitrary bytes. To *materialize* a received bundle's logs (serve them, write pack files, hand them on), use `disclosedContent` rather than decoding that field yourself:
142
+
143
+ ```ts
144
+ import { disclosedContent } from "@sanning/proof";
145
+
146
+ const result = await disclosedContent(bundle); // verifies, then decodes
147
+
148
+ for (const [eventId, bytes] of result.content) {
149
+ await writeFile(`logs/${eventId}.json`, bytes); // Uint8Array — write it as-is
150
+ }
151
+ ```
152
+
153
+ **Verify-then-return:** `disclosedContent` runs the full `verifyEvidenceBundle` (same options) and hands back bytes **only** for events whose chain verified *and* whose disclosed bytes hash to the committed `content_hash`. There is no path through it that yields bytes that are not the committed ones — and `result.verification` carries the whole `EvidenceBundleResult`, so you verify once and render the verdict from the same call.
154
+
155
+ Every event also appears in `result.events`, in body order, with a status that never lets "nothing was disclosed" pass for "empty":
156
+
157
+ | `status` | `bytes` | Meaning |
158
+ |---|---|---|
159
+ | `verified` | the bytes | Decoded **and** bound to the committed `content_hash` — safe to write as-is. May be zero-length: an empty payload is real, verifiable content. |
160
+ | `not_disclosed` | `null` | Minimal disclosure — the hash was committed, the bytes were not shared. Nothing to write, and not an error. |
161
+ | `undetermined` | `null` | Bytes were disclosed but there is nothing to bind them to (withheld record, or no committed `content_hash`). Withheld rather than returned. |
162
+ | `failed` | `null` | The event's own proof chain failed, or the disclosure did not bind or did not decode. `errors` says which. |
163
+
164
+ Bytes come back as `Uint8Array`, never a string: the spec commits to **raw bytes**, and a UTF-8 decode is lossy for any payload that is not valid UTF-8 — re-encoding such a string would change the bytes and break the hash. Decode to text yourself if you know your payload is text.
165
+
114
166
  ## Verify a Merkle inclusion proof
115
167
 
116
168
  Agents close daily checkpoints over per-cycle leaves (RFC 9162, §2.1 domain separation — not the Bitcoin duplicate-leaf variant). Verify a leaf's inclusion against an anchored root:
@@ -133,6 +185,8 @@ const ok = await verifyInclusion(
133
185
  |---|---|
134
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`. |
135
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. |
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. |
136
190
  | `verifyAgentProofBundle(bundle, { gateways?, fetchImpl? })` / `isAgentProofSpec(v)` | Verify the agent producer's `sanning.agent.proof/v1` inclusion bundle. |
137
191
  | `contentHashes(env)` | The content hash(es) an envelope commits to, by event type — the reverse-provenance join keys. |
138
192
  | `specVersionSupported(v)` | Fail-closed accept-check: `sanning.agent/v1` and additive minors (`sanning.agent/v1.<n>`). Unknown majors/profiles are rejected. |
@@ -149,12 +203,13 @@ The primary signature covers `JCS(envelope minus signature minus co_signatures)`
149
203
 
150
204
  ## Why you can trust it (without trusting us)
151
205
 
152
- 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.
153
207
 
154
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.
155
209
 
156
210
  ## What this package deliberately does NOT do
157
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.
158
213
  - **Single-envelope scope.** Chain walking (`previous_hash`), checkpoint reconciliation, and gateway/transport logic are consumer-layer concerns composed above the kernel.
159
214
  - **Provenance is history, not endorsement.** A verified envelope proves *these bytes have this signed, anchored history* — never "safe," "approved," or "currently in production."
160
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";
@@ -57,15 +58,18 @@ export async function runCli(argv, io) {
57
58
  io.out("@sanning/proof verify CLI");
58
59
  return EXIT_VERIFIED;
59
60
  }
60
- if (command === "export") {
61
- return runExport(rest, io);
61
+ // `bundle` is the customer-facing verb (proof#19: one artifact, one word).
62
+ // `export` is kept working as a QUIET alias — it shipped and is published, so
63
+ // it is not broken abruptly; it is simply no longer taught anywhere.
64
+ if (command === "bundle" || command === "export") {
65
+ return runBundle(rest, io);
62
66
  }
63
67
  if (command !== "verify") {
64
68
  io.err(`unknown command: ${command}`);
65
69
  printUsage(io);
66
70
  return EXIT_MALFORMED;
67
71
  }
68
- // 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
69
73
  // positional (<bundle> [gateways]). --logs feeds disclosed raw-log bytes for
70
74
  // content verification (the evidence-bundle path only).
71
75
  let logsPath;
@@ -75,7 +79,7 @@ export async function runCli(argv, io) {
75
79
  if (a === "--logs") {
76
80
  const next = rest[i + 1];
77
81
  if (next === undefined) {
78
- io.err("verify: --logs requires a file path");
82
+ io.err("verify: --logs requires a path (a logs directory, or a JSON map file)");
79
83
  printUsage(io);
80
84
  return EXIT_MALFORMED;
81
85
  }
@@ -95,34 +99,15 @@ export async function runCli(argv, io) {
95
99
  .split(",")
96
100
  .map((g) => g.trim())
97
101
  .filter((g) => g.length > 0);
98
- let content;
102
+ let logs;
99
103
  if (logsPath !== undefined) {
100
- let logsRaw;
101
- try {
102
- logsRaw = await readFile(logsPath, "utf8");
103
- }
104
- catch (e) {
105
- io.err(`cannot read --logs ${logsPath}: ${e instanceof Error ? e.message : String(e)}`);
106
- return EXIT_MALFORMED;
107
- }
108
- let logsParsed;
109
- try {
110
- logsParsed = JSON.parse(logsRaw);
111
- }
112
- catch (e) {
113
- io.err(`--logs ${logsPath} is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
114
- return EXIT_MALFORMED;
115
- }
116
- if (logsParsed === null || typeof logsParsed !== "object" || Array.isArray(logsParsed)) {
117
- io.err(`--logs ${logsPath} must be a JSON object mapping event_id → disclosed bytes`);
104
+ logs = await loadLogs(logsPath, io);
105
+ if (logs === undefined)
118
106
  return EXIT_MALFORMED;
119
- }
120
- content = {};
121
- for (const [k, v] of Object.entries(logsParsed)) {
122
- if (typeof v === "string")
123
- content[k] = decodeLogValue(v);
124
- }
107
+ for (const n of logs.notes)
108
+ io.err(`note: ${n}`);
125
109
  }
110
+ const content = logs?.content;
126
111
  let raw;
127
112
  try {
128
113
  raw = await readFile(bundlePath, "utf8");
@@ -162,9 +147,15 @@ export async function runCli(argv, io) {
162
147
  ...(content ? { content } : {}),
163
148
  ...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}),
164
149
  });
165
- 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;
166
156
  }
167
- // `proof export <source-bundle.json> --attestations <f> --key <f> [-o <out>]`
157
+ // `proof bundle <source-bundle.json> --attestations <f> --key <f> [-o <out>]`
158
+ // (`proof export` still reaches this, undocumented — see the dispatch above.)
168
159
  //
169
160
  // Compose a signed, offline-verifiable sanning.evidence.export/v1 artifact from a
170
161
  // source sanning.anchor.trace/v1 bundle + operator attestation records + an exporter
@@ -175,7 +166,7 @@ export async function runCli(argv, io) {
175
166
  // forged/mis-bound attestation — the composer refuses to emit a green artifact
176
167
  // over failing evidence) · 2 usage / malformed input (missing flag, unreadable
177
168
  // or non-JSON input, bad exporter key, malformed attestation record).
178
- async function runExport(rest, io) {
169
+ async function runBundle(rest, io) {
179
170
  let attestationsPath;
180
171
  let keyPath;
181
172
  let outPath;
@@ -296,7 +287,7 @@ async function runExport(rest, io) {
296
287
  return composed.status === "failed" ? EXIT_FAILED : EXIT_VERIFIED;
297
288
  }
298
289
  function usageExport(io) {
299
- io.out("Usage: npx @sanning/proof export <source-bundle.json> --attestations <file> --key <file> [-o <out.json>]");
290
+ io.out("Usage: npx @sanning/proof bundle <source-bundle.json> --attestations <file> --key <file> [-o <out.json>]");
300
291
  io.out("");
301
292
  io.out("Compose a signed, offline-verifiable sanning.evidence.export/v1 artifact from an");
302
293
  io.out("sanning.anchor.trace/v1 source bundle + operator attestation records + an exporter");
@@ -377,10 +368,187 @@ async function readExporterKey(path, io) {
377
368
  }
378
369
  return seed.toLowerCase();
379
370
  }
380
- // 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,
381
548
  // even-length, lowercase-hex string is hex bytes; anything else is utf8 text.
382
549
  // (The verifier itself only distinguishes "string ⇒ hex" from "Uint8Array ⇒
383
- // 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.)
384
552
  function decodeLogValue(v) {
385
553
  if (v.length > 0 && v.length % 2 === 0 && /^[0-9a-f]+$/.test(v))
386
554
  return v; // hex passthrough
@@ -390,18 +558,29 @@ function printUsage(io) {
390
558
  io.out("Usage: npx @sanning/proof <command> [args]");
391
559
  io.out("");
392
560
  io.out("Commands:");
393
- io.out(" verify <bundle.json> [gateways] [--logs <logs.json>] Verify a bundle/export");
394
- io.out(" export <source.json> --attestations <f> --key <f> [-o] Compose an attested export");
561
+ io.out(" verify <bundle.json> [gateways] [--logs <path>] Verify a bundle");
562
+ io.out(" bundle <source.json> --attestations <f> --key <f> [-o] Create an attested bundle");
395
563
  io.out("");
396
564
  io.out("Verify a sanning.evidence/v1 (sanning.anchor.trace/v1) bundle, an");
397
565
  io.out("sanning.evidence.export/v1 attested export, or a sanning.agent.proof/v1");
398
566
  io.out("inclusion bundle, fully offline. Supply a comma-separated gateway list");
399
567
  io.out("to also re-fetch each checkpoint on-chain.");
400
568
  io.out("");
401
- io.out("--logs <logs.json> Bind disclosed raw logs to each event's committed");
402
- io.out(" content_hash. JSON object mapping event_id → bytes");
403
- io.out(" (even-length lowercase hex ⇒ hex, else utf8 text).");
404
- 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.");
405
584
  io.out("");
406
585
  io.out("Exit codes: 0 verified · 1 failed · 2 malformed · 3 gateway-unavailable");
407
586
  }
@@ -420,7 +599,7 @@ function reportEvidence(result, path, gateways, io) {
420
599
  io.out(paint(" Checkpoints", DIM));
421
600
  for (const cp of result.checkpoints) {
422
601
  const onChain = cp.onChainOk === null ? "" : ` on-chain ${mark(cp.onChainOk)}`;
423
- 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}`);
424
603
  for (const e of cp.errors)
425
604
  io.out(paint(` - ${e}`, R));
426
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,10 +271,39 @@ 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>;
278
+ export type DisclosureStatus = "verified" | "not_disclosed" | "undetermined" | "failed";
279
+ export interface DisclosedEventContent {
280
+ eventId: string;
281
+ status: DisclosureStatus;
282
+ bytes: Uint8Array | null;
283
+ errors: string[];
284
+ }
285
+ export interface DisclosedContentResult {
286
+ status: EvidenceStatus;
287
+ verification: EvidenceBundleResult;
288
+ events: DisclosedEventContent[];
289
+ content: Map<string, Uint8Array>;
290
+ errors: string[];
291
+ }
292
+ /**
293
+ * Materialize the disclosed raw bytes of a received bundle (evidence-bundle §5.1).
294
+ *
295
+ * Verify-then-return: the bundle is verified with {@link verifyEvidenceBundle}
296
+ * (same options — gateways, out-of-band `content`, `fetchImpl`), and an event's
297
+ * bytes are returned ONLY when that verification bound them to the committed
298
+ * `content_hash`. There is no path through this function that yields bytes
299
+ * whose SHA-256 is not the commitment.
300
+ *
301
+ * Returns `Uint8Array`, never a string: the spec commits to raw bytes, and a
302
+ * UTF-8 decode is lossy for any payload that is not valid UTF-8 — re-encoding
303
+ * such a string would produce different bytes and a different hash. Callers who
304
+ * know their payload is text decode it themselves and own that assumption.
305
+ */
306
+ export declare function disclosedContent(bundle: unknown, options?: VerifyEvidenceOptions): Promise<DisclosedContentResult>;
208
307
  export interface ComposeVerdictResult {
209
308
  verdict: VerdictObject;
210
309
  sourceStatus: EvidenceStatus;