@sanning/proof 0.19.0 → 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 +343 -810
  9. package/dist/case.js +693 -1151
  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 +848 -1322
  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 +132 -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 +597 -580
  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/cli.ts CHANGED
@@ -1,48 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
  // sanning-proof verify <bundle.json> [--gateways <url,url>]
3
- // (`npx @sanning/proof verify ...` without installing: npm runs a package's
4
- // only binary whatever it is called.)
5
3
  //
6
- // One turnkey verifier for both producer bundles in the stack:
7
- // - sanning.evidence/v1: bodies sanning.anchor.trace/v3, sanning.stamp.trace/v1,
8
- // sanning.stamp.evidence/v2 and sanning.evidence.export/v2
9
- // - sanning.agent.proof/v1: the agent inclusion proof
10
- // Dispatch is by sniffed spec_version; no flag tells the CLI which it is.
4
+ // Verifies sanning.evidence/v1 bundles (bodies sanning.anchor.trace/v3,
5
+ // sanning.stamp.trace/v1, sanning.stamp.evidence/v2, sanning.evidence.export/v2).
11
6
  //
12
- // Exit codes: one table for every verb, `./exit-codes.ts` (0.13.0):
13
- // 0 verified, or the export was written
14
- // 1 failed (bad signature / tamper / broken inclusion / on-chain mismatch)
15
- // 2 usage or malformed (unparseable, unknown spec major, body_hash mismatch)
16
- // 3 UNDETERMINED: partial, pending, incomplete or not_found. A gateway that
17
- // could not be reached reads `partial`; a pack whose records nobody hashed
18
- // reads `incomplete` (see below)
19
- // 4 mixed
20
- // 5 sandbox: a record is signed environment dev; would be 0, or only a
21
- // simulated witness is unchecked (`sandboxExit`)
22
- // `sanningd verify-status` in sanning-io/agent mirrored the 0.12 table, which
23
- // exited 0 on `partial`; it moves with this one.
7
+ // Exit codes, one table for every verb (`./exit-codes.ts`): 0 verified, 1 failed,
8
+ // 2 incomplete, 3 verified sandbox evidence (a record is signed environment dev),
9
+ // 4 could not run. 2 is not a failure: a withheld record or bytes nobody handed
10
+ // over are not proven, never disproven.
24
11
  //
25
- // ── The headline is the PACK verdict, not the bundle status (proof#110) ──
26
- //
27
- // This command used to print `result.status` as its headline and map it to an
28
- // exit code directly. That is the verdict on the signed BUNDLE, and it is not
29
- // the verdict the rest of the product prints. `gradePack` in `./grading.ts` is
30
- // the kernel's one rule for what a verification SURFACE may claim about a pack,
31
- // the browser verify page calls it, and this command referenced it zero times.
32
- // So the same file read `Incomplete` on the page and `VERIFIED`, exit 0, at the
33
- // terminal: two answers to one question, from one kernel, which is the exact
34
- // defect `grading.ts` was brought into the kernel to end.
35
- //
36
- // The two layers stay distinct, deliberately (`grading.ts` says so in its own
37
- // comments, and `incomplete` is a pack verdict in `evidence-bundle.md` §3,
38
- // never a bundle status): the BUNDLE status is still reported, unchanged, on
39
- // its own line. What changed is
40
- // which of the two is the headline and which one the exit code follows.
41
- //
42
- // `payloadHashOk === null` (a withheld record) is semantics-undetermined and
43
- // surfaces as a per-event note, and it does NOT fail the run. It does hold the
44
- // PACK at `incomplete`, because a record nobody hashed is a record nobody
45
- // checked, and exit 3 is how this command says "undetermined".
12
+ // The headline is the pack verdict, read off the six claims (`claims.ts`), the
13
+ // same rule the verify page prints. The report prints the person layer first
14
+ // (the title, one line per claim that is not proven, the next action, the notes,
15
+ // the sandbox line and the witness line), then the detail.
46
16
 
47
17
  import { lstat, open, readFile, readdir, stat, writeFile } from "node:fs/promises";
48
18
  import { constants as fsConstants, realpathSync } from "node:fs";
@@ -50,12 +20,11 @@ import { createHash, randomBytes } from "node:crypto";
50
20
  import { basename, resolve, sep } from "node:path";
51
21
  import { fileURLToPath } from "node:url";
52
22
 
53
- import { isAgentProofSpec, verifyAgentProof } from "./agent-proof.js";
54
- import type { AgentProofResult } from "./agent-proof.js";
23
+ import { renderAgentReading, type AgentRecordReading } from "./agent-record.js";
24
+
55
25
  import { CHAIN_READING_SCOPE, readChainLinks, readChains, renderChainFinding } from "./chain.js";
56
26
 
57
- // What this command holds after asking for a chain reading: the reading, or a
58
- // named statement that one could not be produced. Three states, not two.
27
+ // The reading, or a named statement that one could not be produced.
59
28
  type ChainOutcome = ChainReading | { unreadable: string };
60
29
  import type { ChainLink, ChainReading } from "./chain.js";
61
30
  import {
@@ -66,6 +35,7 @@ import {
66
35
  renderCaseSummary,
67
36
  renderException,
68
37
  renderWitnessLine,
38
+ type WitnessLine,
69
39
  } from "./case.js";
70
40
  import {
71
41
  CASE_FILE_INDEX_NAME,
@@ -80,9 +50,6 @@ import { composeExport } from "./compose.js";
80
50
  import type { ComposeExportOptions } from "./compose.js";
81
51
  import { isHexField, sha256Hex, utf8 } from "./crypto.js";
82
52
  import { verifyEvidenceBundle } from "./evidence.js";
83
- import { readRuns, renderAccountLine, renderEnclaveLines } from "./run.js";
84
- import type { RunReading } from "./run.js";
85
- import { loadInstalledAdapters } from "./run-adapters.js";
86
53
  import { DuplicateKeyError, parseRefusingDuplicates } from "./verifier.js";
87
54
  import {
88
55
  fileMatchToJson,
@@ -97,36 +64,46 @@ import {
97
64
  WITNESS_CACHE_NAME,
98
65
  readWitnessCache,
99
66
  } from "./witness-cache.js";
100
- // The public-record (witness) lookup this command runs with `--gateways`:
101
- // moved to its own pure module (proof#200) so a consumer that is not this CLI,
102
- // the verify page, can call the SAME implementation. `onChainHalf` and
103
- // `witnessLine` are this command's own two building blocks, unchanged; see
104
- // `./witness-lookup.ts` for the exported entry point, `lookupPublicRecord`.
105
- import { type OnChainHalf, onChainHalf, witnessLine } from "./witness-lookup.js";
106
- import type { AttestationRecord, EvidenceBundle, EvidenceBundleResult, ExportResult } from "./evidence.js";
67
+ import {
68
+ type OnChainHalf,
69
+ onChainHalf,
70
+ onChainOutcome,
71
+ onChainRow,
72
+ witnessLine,
73
+ } from "./witness-lookup.js";
74
+ import type { AttestationRecord, EvidenceBundle, EvidenceBundleResult } from "./evidence.js";
107
75
  import { checkDeclaration, renderDeclarationSummary, renderNote } from "./declaration.js";
108
76
  import { completenessNotes, gradePack, renderCause } from "./grading.js";
109
- import type { DisclosureAccount, PackGrade } from "./grading.js";
77
+ import type { DisclosureAccount, PackGrade, PackGradeCause } from "./grading.js";
110
78
 
111
79
  import {
112
80
  EXIT_CODES_HELP,
81
+ EXIT_COULD_NOT_RUN,
113
82
  EXIT_FAILED,
114
- EXIT_MALFORMED,
115
- EXIT_SANDBOX,
116
- EXIT_UNDETERMINED,
117
83
  EXIT_VERIFIED,
118
84
  exitCodeFor,
119
- sandboxExit,
120
85
  } from "./exit-codes.js";
86
+ import { type EnvironmentMarker, environmentMarker, simulatedOnlyRun } from "./environment.js";
121
87
  import {
122
- type EnvironmentMarker,
123
- environmentMarker,
124
- onlySimulatedUnsettled,
125
- simulatedOnlyRun,
126
- SANDBOX_HEADLINE,
127
- SANDBOX_LINE,
128
- SANDBOX_TITLE,
129
- } from "./environment.js";
88
+ VERDICT_TITLES,
89
+ bundleProblems,
90
+ claimsOf,
91
+ note,
92
+ type NoteEntry,
93
+ type Claim,
94
+ type NotInScopeReason,
95
+ orderedNotes,
96
+ personText,
97
+ problem,
98
+ renderFindingNote,
99
+ renderPerson,
100
+ restOnSigned,
101
+ verdictOf,
102
+ type ClaimEntry,
103
+ type Person,
104
+ type Problem,
105
+ type VerdictJson,
106
+ } from "./claims.js";
130
107
 
131
108
  interface Cli {
132
109
  out: (s: string) => void;
@@ -134,25 +111,20 @@ interface Cli {
134
111
  fetchImpl?: typeof fetch;
135
112
  }
136
113
 
137
- // ── What a person reads is escaped at one choke point ──
138
- //
139
- // Every human-readable line this command prints passes through `guard`, which
140
- // `runCli` and `runCase` apply to the streams they are handed. It escapes each
141
- // control character, so a value the audited party wrote (a folder or file
142
- // name, a `contents.json` field, a zip entry name, an exception built from
143
- // one) cannot move the cursor, clear the screen or reorder the line. Before
144
- // this, a case file whose name carried `\x1b[2J\x1b[H` printed "Verified" over
145
- // a FAILED headline. `outJson` is the one way around it, and it carries JSON
146
- // only, which escapes its own control characters. The twin is `Io` in
147
- // `src/sanning_proof/cli.py`, and the two print byte-identical escapes.
114
+ // Every human-readable line passes through `guard`, which escapes control
115
+ // characters, so a value the audited party wrote (a file name, a zip entry, a
116
+ // field) cannot move the cursor or overwrite a headline. `outJson` is the one
117
+ // way around it, and JSON escapes its own control characters. Python's `Io` in
118
+ // `cli.py` prints byte-identical escapes.
148
119
 
149
120
  // C0, DEL, C1, the two Unicode line and paragraph separators, and the bidi
150
121
  // embedding, override and isolate controls.
151
122
  const CONTROL_CHARACTERS = /[\x00-\x1f\x7f-\x9f\u2028\u2029\u202a-\u202e\u2066-\u2069]/g;
152
123
 
153
- /** Each control character in `text` as a visible escape: `\x1b` for ESC,
154
- * `\u202e` for RIGHT-TO-LEFT OVERRIDE. Every other character is unchanged, a
155
- * backslash included, so a line with none is printed as it was. */
124
+ /**
125
+ * Each control character in `text` as a visible escape (`\x1b`, `‮`).
126
+ * Every other character, a backslash included, is unchanged.
127
+ */
156
128
  export function escapeControl(text: string): string {
157
129
  return text.replace(CONTROL_CHARACTERS, (c) => {
158
130
  const code = c.charCodeAt(0);
@@ -162,16 +134,12 @@ export function escapeControl(text: string): string {
162
134
  });
163
135
  }
164
136
 
165
- // The colors this command writes on a TTY are its own control characters, and
166
- // the choke point would escape them with everything else. So `paint` and
167
- // `mark` write this marker instead, and the choke point turns it into ESC
168
- // after escaping. It is random per process, so no input can spell it.
137
+ // `paint` and `mark` write this marker instead of ESC, so the escaping choke
138
+ // point does not escape our own colors; it becomes ESC after escaping. Random
139
+ // per process, so no input can spell it.
169
140
  const COLOR_MARK = `sgr${randomBytes(16).toString("hex")}:`;
170
141
  const G = `${COLOR_MARK}32m`;
171
142
  const R = `${COLOR_MARK}31m`;
172
- const Y = `${COLOR_MARK}33m`;
173
- const DIM = `${COLOR_MARK}2m`;
174
- const B = `${COLOR_MARK}1m`;
175
143
  const X = `${COLOR_MARK}0m`;
176
144
 
177
145
  function forTerminal(text: string): string {
@@ -201,8 +169,7 @@ function outJson(io: Cli, text: string): void {
201
169
  rawOf(io).out(text);
202
170
  }
203
171
 
204
- /** A block this command wrote itself, such as a usage text: its own newlines
205
- * are kept, and each line is escaped as `out` escapes it. */
172
+ /** A block this command wrote itself: its newlines are kept, each line escaped. */
206
173
  function outBlock(io: Cli, text: string): void {
207
174
  rawOf(io).out(text.split("\n").map(forTerminal).join("\n"));
208
175
  }
@@ -213,13 +180,6 @@ function useColor(): boolean {
213
180
  return Boolean(process.stdout.isTTY);
214
181
  }
215
182
 
216
- function mark(ok: boolean | null): string {
217
- const c = useColor();
218
- if (ok === null) return c ? `${Y}~${X}` : "~";
219
- if (ok) return c ? `${G}✓${X}` : "✓";
220
- return c ? `${R}✗${X}` : "✗";
221
- }
222
-
223
183
  function paint(s: string, c: string): string {
224
184
  return useColor() ? `${c}${s}${X}` : s;
225
185
  }
@@ -230,16 +190,13 @@ export async function runCli(argv: string[], stream: Cli): Promise<number> {
230
190
 
231
191
  if (command === undefined || command === "help" || command === "--help" || command === "-h") {
232
192
  printUsage(io);
233
- return command === undefined ? EXIT_MALFORMED : EXIT_VERIFIED;
193
+ return command === undefined ? EXIT_COULD_NOT_RUN : EXIT_VERIFIED;
234
194
  }
235
195
  if (command === "version" || command === "--version" || command === "-v") {
236
196
  io.out("@sanning/proof verify CLI");
237
197
  return EXIT_VERIFIED;
238
198
  }
239
- // `export` composes an attested export (`sanning.evidence.export/v2`). It
240
- // was taught as `bundle` until 0.13.0, and `bundle` is now the one verb that
241
- // builds a pack (`anchor bundle`, DL-59), so this command no longer answers
242
- // to it: `proof bundle` is an unknown command and exits 2, with no alias.
199
+ // `export` composes an attested export (`sanning.evidence.export/v2`).
243
200
  if (command === "export") {
244
201
  if (rest.includes("--json")) {
245
202
  return runJson(
@@ -256,7 +213,7 @@ export async function runCli(argv: string[], stream: Cli): Promise<number> {
256
213
  if (command !== "verify") {
257
214
  io.err(`unknown command: ${command}`);
258
215
  printUsage(io);
259
- return EXIT_MALFORMED;
216
+ return EXIT_COULD_NOT_RUN;
260
217
  }
261
218
  if (rest.includes("--json")) {
262
219
  return runJson(
@@ -268,16 +225,10 @@ export async function runCli(argv: string[], stream: Cli): Promise<number> {
268
225
  return runVerify(rest, io, null);
269
226
  }
270
227
 
271
- // ─── `--json` on `verify` and `export` (C-03, 0.13.0) ──────────────────────
272
- //
273
- // `case --json` printed the reading as JSON and the other two verbs had no
274
- // machine-readable form, so a script parsed the text. Each verb now runs as it
275
- // does for a person, with its text discarded, and hands this one object back
276
- // through `sink`: the same keys in both kernels, sorted at every depth, and
277
- // printed byte for byte the same (`cross-kernel/run_cli.sh`,
278
- // `cross-kernel/run_export_cli.sh`). A run that stops before it has anything to
279
- // report (a bad flag, an unreadable file) prints `{ error, exit_code }`, as
280
- // `case --json` does. `note:` lines still go to stderr.
228
+ // `--json` on `verify` and `export`: the verb runs as for a person with its
229
+ // text discarded, and hands one object back through `sink`. Same keys in both
230
+ // kernels, sorted at every depth, printed byte for byte the same. A run that
231
+ // stops early prints `{ error, exit_code }`. `note:` lines still go to stderr.
281
232
  interface JsonSink {
282
233
  data: Record<string, unknown> | null;
283
234
  }
@@ -306,23 +257,25 @@ async function runJson(rest: string[], io: Cli, verb: JsonVerb): Promise<number>
306
257
 
307
258
  async function runVerify(rest: string[], io: Cli, sink: JsonSink | null): Promise<number> {
308
259
 
309
- // `--logs <path>` feeds disclosed raw-log bytes for content verification
310
- // (the evidence-bundle path only); `--gateways <url,url>` asks for the
311
- // on-chain re-fetch, as it does on `case` (0.13.0: the gateway list was a
312
- // second positional argument until then, and a second positional is now a
313
- // usage error). The one positional is the bundle.
260
+ // `--logs <path>` supplies disclosed raw-log bytes (evidence-bundle path
261
+ // only); `--gateways <url,url>` asks for the on-chain re-fetch. The one
262
+ // positional is the bundle.
314
263
  let logsPath: string | undefined;
315
264
  let filePaths: string[] | undefined;
316
265
  let gatewayArg: string | undefined;
317
266
  const positional: string[] = [];
318
267
  for (let i = 0; i < rest.length; i++) {
319
268
  const a = rest[i]!;
269
+ if ((a === "--help" || a === "-h") && positional.length === 0) {
270
+ printUsage(io);
271
+ return EXIT_VERIFIED;
272
+ }
320
273
  if (a === "--gateways") {
321
274
  const next = rest[i + 1];
322
275
  if (next === undefined) {
323
276
  io.err("verify: --gateways requires a comma-separated list of URLs");
324
277
  printUsage(io);
325
- return EXIT_MALFORMED;
278
+ return EXIT_COULD_NOT_RUN;
326
279
  }
327
280
  gatewayArg = next;
328
281
  i++;
@@ -333,7 +286,7 @@ async function runVerify(rest: string[], io: Cli, sink: JsonSink | null): Promis
333
286
  if (next === undefined) {
334
287
  io.err("verify: --logs requires a path (a logs directory, or a JSON map file)");
335
288
  printUsage(io);
336
- return EXIT_MALFORMED;
289
+ return EXIT_COULD_NOT_RUN;
337
290
  }
338
291
  logsPath = next;
339
292
  i++;
@@ -344,7 +297,7 @@ async function runVerify(rest: string[], io: Cli, sink: JsonSink | null): Promis
344
297
  if (taken.length === 0) {
345
298
  io.err("verify: --files requires at least one file path");
346
299
  printUsage(io);
347
- return EXIT_MALFORMED;
300
+ return EXIT_COULD_NOT_RUN;
348
301
  }
349
302
  filePaths = [...(filePaths ?? []), ...taken];
350
303
  i += taken.length;
@@ -357,12 +310,12 @@ async function runVerify(rest: string[], io: Cli, sink: JsonSink | null): Promis
357
310
  if (!bundlePath) {
358
311
  io.err("verify: a bundle file path is required");
359
312
  printUsage(io);
360
- return EXIT_MALFORMED;
313
+ return EXIT_COULD_NOT_RUN;
361
314
  }
362
315
  if (positional.length > 1) {
363
316
  io.err("verify: takes one bundle; pass gateways as --gateways <url,url>");
364
317
  printUsage(io);
365
- return EXIT_MALFORMED;
318
+ return EXIT_COULD_NOT_RUN;
366
319
  }
367
320
  const gateways = (gatewayArg ?? "")
368
321
  .split(",")
@@ -372,7 +325,7 @@ async function runVerify(rest: string[], io: Cli, sink: JsonSink | null): Promis
372
325
  let logs: LoadedLogs | undefined;
373
326
  if (logsPath !== undefined) {
374
327
  logs = await loadLogs(logsPath, io);
375
- if (logs === undefined) return EXIT_MALFORMED;
328
+ if (logs === undefined) return EXIT_COULD_NOT_RUN;
376
329
  for (const n of logs.notes) io.err(`note: ${n}`);
377
330
  }
378
331
  const content = logs?.content;
@@ -383,7 +336,7 @@ async function runVerify(rest: string[], io: Cli, sink: JsonSink | null): Promis
383
336
  const read = await readOfferedFiles(filePaths);
384
337
  if (typeof read === "string") {
385
338
  io.err(`verify: ${read}`);
386
- return EXIT_MALFORMED;
339
+ return EXIT_COULD_NOT_RUN;
387
340
  }
388
341
  offered = read.offered;
389
342
  skipped = read.skipped;
@@ -394,12 +347,10 @@ async function runVerify(rest: string[], io: Cli, sink: JsonSink | null): Promis
394
347
  raw = new Uint8Array(await readFile(bundlePath));
395
348
  } catch (e) {
396
349
  io.err(`cannot read ${bundlePath}: ${e instanceof Error ? e.message : String(e)}`);
397
- return EXIT_MALFORMED;
350
+ return EXIT_COULD_NOT_RUN;
398
351
  }
399
352
 
400
- // The one reader both commands use, in both kernels: `case` reads each pack's
401
- // bundle.json through it too, so the two commands classify one bundle the
402
- // same way (see `readBundleBytes`).
353
+ // The same reader `case` uses, so both commands classify a bundle alike.
403
354
  let parsed: unknown;
404
355
  try {
405
356
  parsed = readBundleBytes(raw);
@@ -411,79 +362,62 @@ async function runVerify(rest: string[], io: Cli, sink: JsonSink | null): Promis
411
362
  } else {
412
363
  io.err(`${bundlePath} is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
413
364
  }
414
- return EXIT_MALFORMED;
365
+ return EXIT_COULD_NOT_RUN;
415
366
  }
416
-
417
- const specVersion =
418
- parsed && typeof parsed === "object" && !Array.isArray(parsed)
419
- ? (parsed as { spec_version?: unknown }).spec_version
420
- : undefined;
421
-
422
- if (isAgentProofSpec(specVersion)) {
423
- if (content) {
424
- // --logs binds raw logs to anchor-trace events; an agent inclusion proof
425
- // has no such per-event content. Say so rather than silently ignoring it.
426
- io.err("note: --logs has no effect on a sanning.agent.proof/v1 bundle (ignored)");
427
- }
428
- if (offered) {
429
- io.err("note: --files has no effect on a sanning.agent.proof/v1 bundle (ignored)");
430
- }
431
- const result = await verifyAgentProof(parsed, {
432
- gateways,
433
- ...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}),
434
- });
435
- return reportAgentProof(result, bundlePath, gateways, io, sink, parsed);
367
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
368
+ // Not a pack at all: nothing in it names a claim to read.
369
+ io.err(`${bundlePath} is not an evidence bundle: not a JSON object`);
370
+ return EXIT_COULD_NOT_RUN;
436
371
  }
437
372
 
438
- // Default: treat anything else as an evidence bundle. verifyEvidenceBundle
439
- // rejects an unknown major as "malformed" so a genuinely unknown shape still
440
- // exits 2 with a clear message.
373
+ // An evidence bundle; an unknown major is unreadable, and incomplete.
441
374
  const result = await verifyEvidenceBundle(parsed, {
442
375
  gateways,
443
376
  ...(content ? { content } : {}),
444
377
  ...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}),
445
378
  });
446
- // Read the chains from the SAME parsed bundle the verifier saw. Findings are
447
- // evidence about the records in front of us, not a verdict: they are printed
448
- // and they move `exit` only through the pack's own declaration, where a v3
449
- // declaration claims a chain complete and in order and a finding under that
450
- // claim contradicts it (rule 11). Without a claim a chain is not a
451
- // completeness proof, and an exit code is exactly the kind of thing people
452
- // read as one.
453
- //
454
- // A READING THAT COULD NOT BE PRODUCED SAYS SO. This was an unguarded call:
455
- // a throw out of `readChains` would have escaped the command entirely and
456
- // exited 1, which is the code for "failed" and would have named the
457
- // customer's evidence tampered over a fault in this reader. The Python twin
458
- // had the opposite half of the same defect and swallowed the throw into "no
459
- // event chain was found to follow in this bundle" (the gate on proof#154).
460
- // Neither kernel is entitled to either answer, so both now print the third.
379
+ // The same pack read without the bytes handed in beside it, so a disproof that only those
380
+ // bytes make reads as not proven (evidence-bundle.md section 5.1).
381
+ // Offline: what the public record holds does not turn on the bytes beside the pack.
382
+ const bare = content ? await verifyEvidenceBundle(parsed) : null;
383
+ // Chain findings are evidence, not a verdict: they move `exit` only through a
384
+ // v3 declaration's chain claim (rule 11). A reading that cannot be produced
385
+ // says so, rather than exiting 1 (failed) over a fault in this reader.
461
386
  let chains: ChainOutcome;
462
387
  try {
463
388
  chains = await readChains(parsed);
464
389
  } catch (e) {
465
390
  chains = { unreadable: errMsg(e) };
466
391
  }
467
- // The links rule 11 holds a claimed chain to (evidence-bundle.md 5.4.2), from
468
- // the same parsed bundle. Links that cannot be read are none, which reads a
469
- // claim UNKNOWN and never clean.
392
+ // Links for rule 11. Unreadable links are none, which reads a claim unknown,
393
+ // never clean.
470
394
  let links: ChainLink[] | null;
471
395
  try {
472
396
  links = "unreadable" in chains ? null : await readChainLinks(parsed);
473
397
  } catch {
474
398
  links = null;
475
399
  }
476
- const exit = reportEvidence(result, bundlePath, gateways, io, chains, links, parsed, logs, sink);
477
- // Files handed in beside the pack (evidence-bundle.md 5.7, A29): findings
478
- // beside the verdict, never inside it, so `exit` is untouched.
400
+ const exit = reportEvidence(
401
+ result,
402
+ bundlePath,
403
+ gateways,
404
+ io,
405
+ chains,
406
+ links,
407
+ parsed,
408
+ logs,
409
+ sink,
410
+ bare,
411
+ );
412
+ // Files handed in beside the pack (evidence-bundle.md §5.7): findings beside
413
+ // the verdict, never inside it.
479
414
  if (offered !== undefined) {
480
415
  const reading = { ...(await matchFiles(parsed, offered, content)), skipped };
481
416
  for (const line of renderFileMatch(reading)) io.out(line);
482
417
  if (sink !== null && sink.data !== null) sink.data.files = fileMatchToJson(reading);
483
418
  }
484
- // Coverage is reported only once the bundle parsed far enough to have events
485
- // to compare against; over a MALFORMED bundle there is nothing to say.
486
- if (logs && result.status !== "malformed") reportLogsCoverage(logs, result, io);
419
+ // Over a malformed bundle there are no events to compare against.
420
+ if (logs && result.unreadable !== true) reportLogsCoverage(logs, result, io);
487
421
  return exit;
488
422
  }
489
423
 
@@ -497,35 +431,30 @@ function takeFilePaths(args: readonly string[], start: number): string[] {
497
431
  return out;
498
432
  }
499
433
 
500
- // A file is hashed in pieces this big, so a file is read without being held.
501
434
  const HASH_CHUNK = 1 << 20;
502
435
 
503
- // The most a command reads from the files it finds in folders it was handed, in
504
- // all: the cap the zip reader puts on an archive. A file that would push the
505
- // total past it is skipped as `too_large` and never read past it, so a sparse
506
- // file of a terabyte cannot hold a command for hours (A41). A file a person
507
- // names on the command line is the person's choice and is read at any size.
436
+ // The total a command reads from files found in folders it was handed. A file
437
+ // that would pass it is skipped as `too_large` unread, so a sparse terabyte
438
+ // cannot stall the command. A file named on the command line is read at any
439
+ // size.
508
440
  export const MAX_FOLDER_BYTES = MAX_ZIP_BYTES;
509
441
 
510
- // What `hashFile` returns for a file that would push the total past the cap.
511
442
  export const TOO_LARGE = "too_large";
512
443
 
513
- // A child of a folder, by concatenation. `path.join` collapses `link/..`
514
- // lexically, where the operating system, and the Python command, resolve it
515
- // on disk: the same typed path then names two different folders (proof#219).
444
+ // By concatenation: `path.join` collapses `link/..` lexically, while the OS and
445
+ // the Python command resolve it on disk.
516
446
  function childPath(directory: string, name: string): string {
517
447
  return directory.endsWith("/") ? `${directory}${name}` : `${directory}/${name}`;
518
448
  }
519
449
 
520
450
  // The SHA-256 of a regular file, read in pieces, or null when it cannot be
521
- // read. Inside a folder the file is opened without following a link and
522
- // without blocking, then checked again, so a file swapped for a link or a pipe
523
- // after the walk listed it is skipped and never followed or waited on. A path a
524
- // person typed (`follow`) is opened as the shell would.
451
+ // read. Inside a folder the file is opened without following a link or
452
+ // blocking, then rechecked, so a file swapped for a link or pipe is skipped. A
453
+ // typed path (`follow`) is opened as the shell would.
525
454
  //
526
- // `budget` is the bytes still allowed across the folders of a run. A file
527
- // larger than it returns TOO_LARGE before a byte is read, and so does a file
528
- // that grows past it while it is read; only a file read whole is charged.
455
+ // `budget` is the bytes still allowed across the run's folders. A file larger
456
+ // than it, or one that grows past it while read, returns TOO_LARGE; only a file
457
+ // read whole is charged.
529
458
  export async function hashFile(
530
459
  path: string,
531
460
  follow: boolean,
@@ -563,15 +492,11 @@ export async function hashFile(
563
492
  }
564
493
  }
565
494
 
566
- // The regular files under a `--files` folder, recursively, hidden files
567
- // included, into `offered` as `<folder>/<path inside>` in UTF-16 code unit
568
- // order of that path; a link or anything that is not a regular file, and a
569
- // file or folder that cannot be read, into `skipped`. Returns the one error
570
- // line, or null. Iterative, bounded as the case walk is.
571
- //
572
- // The files are hashed once the walk is done, in the order they are listed, so
573
- // which file a full `budget` turns away does not depend on how the walk
574
- // happened to reach it.
495
+ // The regular files under a `--files` folder, recursively, hidden ones
496
+ // included, into `offered` as `<folder>/<path inside>` in UTF-16 order; links,
497
+ // non-regular and unreadable entries into `skipped`. Returns the one error line,
498
+ // or null. Files are hashed after the walk, in listed order, so which file the
499
+ // budget turns away does not depend on walk order.
575
500
  async function walkOfferedFolder(
576
501
  typed: string,
577
502
  offered: OfferedFile[],
@@ -644,16 +569,12 @@ async function walkOfferedFolder(
644
569
  return null;
645
570
  }
646
571
 
647
- // Each `--files` path as `{ name, sha256 }`, or the one error line.
648
- //
649
- // A path that is a regular file is one file, its name exactly as typed. A path
650
- // that is a folder stands for the regular files inside it, recursively and
651
- // hidden files included, each named `<folder as typed>/<path inside>` (A40,
652
- // proof#214). The rest is returned as `skipped`, named, never dropped. A path
653
- // that is neither a readable file nor a readable folder is the one error line,
654
- // which names the path and not the operating system's words, which differ by
655
- // kernel. A path typed is followed as a shell would; a link found inside a
656
- // folder is not.
572
+ // Each `--files` path as `{ name, sha256 }`, or the one error line. A file is
573
+ // named as typed; a folder stands for its regular files, recursively, named
574
+ // `<folder as typed>/<path inside>`. The rest is returned as `skipped`, never
575
+ // dropped. A typed path is followed as a shell would; a link inside a folder is
576
+ // not. The error line names the path, not the OS's words, which differ by
577
+ // kernel.
657
578
  export async function readOfferedFiles(
658
579
  paths: readonly string[],
659
580
  cap: number = MAX_FOLDER_BYTES,
@@ -683,15 +604,12 @@ export async function readOfferedFiles(
683
604
 
684
605
  // `proof export <source-bundle.json> --attestations <f> --key <f> [--out <out>]`
685
606
  //
686
- // Compose a signed, offline-verifiable sanning.evidence.export/v2 artifact from a
687
- // source sanning.anchor.trace/v3 bundle + operator attestation records + an exporter
688
- // Ed25519 key (evidence-export.md §5, composer). Writes the export to `--out <out>`
689
- // (or stdout when omitted, so it can be piped straight into `proof verify`).
607
+ // Compose a signed, offline-verifiable sanning.evidence.export/v2 artifact
608
+ // (evidence-export.md §5). Writes to `--out`, or stdout so it pipes into
609
+ // `proof verify`.
690
610
  //
691
- // Exit codes: 0 composed OK · 1 the recomputed export is FAILED (a bad source or
692
- // forged/mis-bound attestation — the composer refuses to emit a green artifact
693
- // over failing evidence) · 2 usage / malformed input (missing flag, unreadable
694
- // or non-JSON input, bad exporter key, malformed attestation record).
611
+ // Exit codes: 0 composed; 1 the recomputed export is `failed` (the composer never
612
+ // emits a green artifact over failing evidence); 2 usage or malformed input.
695
613
  async function runExport(rest: string[], io: Cli, sink: JsonSink | null): Promise<number> {
696
614
  if (rest[0] === "--help" || rest[0] === "-h" || rest[0] === "help") {
697
615
  usageExport(io);
@@ -743,9 +661,7 @@ async function runExport(rest: string[], io: Cli, sink: JsonSink | null): Promis
743
661
  if (v === undefined) return usageExport(io);
744
662
  opts.generatedAt = v;
745
663
  } else if (a.startsWith("-")) {
746
- // A flag this verb does not take is a usage error, never a path: `-o`
747
- // was a short form of `--out` until 0.13.0, and read as a path it would
748
- // send the export to stdout while the caller waits for a file.
664
+ // An unknown flag is a usage error, never a path.
749
665
  io.err(`export: unknown option ${a}`);
750
666
  return usageExport(io);
751
667
  } else {
@@ -768,30 +684,28 @@ async function runExport(rest: string[], io: Cli, sink: JsonSink | null): Promis
768
684
  }
769
685
 
770
686
  const sourceBundle = await readJson<EvidenceBundle>(bundlePath, io);
771
- if (sourceBundle === undefined) return EXIT_MALFORMED;
687
+ if (sourceBundle === undefined) return EXIT_COULD_NOT_RUN;
772
688
  const attestationsRaw = await readJson<unknown>(attestationsPath, io);
773
- if (attestationsRaw === undefined) return EXIT_MALFORMED;
689
+ if (attestationsRaw === undefined) return EXIT_COULD_NOT_RUN;
774
690
  const attestations = coerceAttestations(attestationsRaw);
775
691
  if (attestations === undefined) {
776
692
  io.err(`export: ${attestationsPath} must be a JSON array of attestation records ` +
777
693
  `(or an object with an "attestations" array)`);
778
- return EXIT_MALFORMED;
694
+ return EXIT_COULD_NOT_RUN;
779
695
  }
780
696
 
781
697
  const privateKey = await readExporterKey(keyPath, io);
782
- if (privateKey === undefined) return EXIT_MALFORMED;
698
+ if (privateKey === undefined) return EXIT_COULD_NOT_RUN;
783
699
 
784
700
  let composed;
785
701
  try {
786
702
  composed = await composeExport(sourceBundle, attestations, { privateKey }, opts);
787
703
  } catch (e) {
788
- io.err(`export: cannot compose — ${e instanceof Error ? e.message : String(e)}`);
789
- return EXIT_MALFORMED;
704
+ io.err(`export: cannot compose: ${e instanceof Error ? e.message : String(e)}`);
705
+ return EXIT_COULD_NOT_RUN;
790
706
  }
791
707
 
792
- // The composer refuses to hand back a green artifact over failing evidence: a
793
- // FAILED recompute (bad source / forged / mis-bound attestation) exits 1 and
794
- // still writes the artifact so the caller can inspect it.
708
+ // A `failed` recompute exits 1 and still writes the artifact for inspection.
795
709
  const serialized = JSON.stringify(composed.bundle, null, 2) + "\n";
796
710
  if (sink) {
797
711
  sink.data = {
@@ -800,8 +714,7 @@ async function runExport(rest: string[], io: Cli, sink: JsonSink | null): Promis
800
714
  source_bundle_hash: composed.sourceBundleHash,
801
715
  attestations: attestations.length,
802
716
  attestations_bound: composed.boundAttestations,
803
- // Without --out the artifact rides inside this object, since stdout
804
- // carries the object and not the artifact.
717
+ // Without --out, stdout carries this object, so the artifact rides inside it.
805
718
  export: outPath ? null : composed.bundle,
806
719
  };
807
720
  }
@@ -810,7 +723,7 @@ async function runExport(rest: string[], io: Cli, sink: JsonSink | null): Promis
810
723
  await writeFile(outPath, serialized, "utf8");
811
724
  } catch (e) {
812
725
  io.err(`export: cannot write ${outPath}: ${e instanceof Error ? e.message : String(e)}`);
813
- return EXIT_MALFORMED;
726
+ return EXIT_COULD_NOT_RUN;
814
727
  }
815
728
  io.out(
816
729
  `${paint("exported", composed.status === "failed" ? R : G)} ${outPath} ` +
@@ -846,11 +759,10 @@ function usageExport(io: Cli): number {
846
759
  for (const line of EXIT_CODES_HELP.split("\n")) io.out(line);
847
760
  io.out("");
848
761
  io.out("export: 1 means the recomputed export is failed; it is still written.");
849
- return EXIT_MALFORMED;
762
+ return EXIT_COULD_NOT_RUN;
850
763
  }
851
764
 
852
- // Read + parse a JSON file; report a clear error and return undefined on any
853
- // failure (mirrors the verify path's read/parse error handling → exit 2).
765
+ // Read and parse a JSON file, or report the error and return undefined (exit 4).
854
766
  async function readJson<T>(path: string, io: Cli): Promise<T | undefined> {
855
767
  let raw: string;
856
768
  try {
@@ -871,8 +783,7 @@ async function readJson<T>(path: string, io: Cli): Promise<T | undefined> {
871
783
  }
872
784
  }
873
785
 
874
- // Accept either a bare array of attestation records or an object wrapping one
875
- // under `attestations`. Returns undefined for anything else.
786
+ // A bare array of attestation records, or one under `attestations`.
876
787
  function coerceAttestations(raw: unknown): AttestationRecord[] | undefined {
877
788
  if (Array.isArray(raw)) return raw as AttestationRecord[];
878
789
  if (raw !== null && typeof raw === "object") {
@@ -882,9 +793,8 @@ function coerceAttestations(raw: unknown): AttestationRecord[] | undefined {
882
793
  return undefined;
883
794
  }
884
795
 
885
- // Read the exporter Ed25519 key: a file that is either a bare 32-byte hex seed
886
- // (whitespace-trimmed) or a JSON object carrying it under `privateKey` / `seed` /
887
- // `private_key`. Validates the 64-hex-char (32-byte) shape.
796
+ // The exporter Ed25519 key: a bare 32-byte hex seed, or a JSON object carrying
797
+ // it under `privateKey`, `seed` or `private_key`.
888
798
  async function readExporterKey(path: string, io: Cli): Promise<string | undefined> {
889
799
  let raw: string;
890
800
  try {
@@ -915,64 +825,28 @@ async function readExporterKey(path: string, io: Cli): Promise<string | undefine
915
825
  return seed.toLowerCase();
916
826
  }
917
827
 
918
- // ─── the `--logs` side input ────────────────────────────────────────────────
919
- //
920
- // Two shapes, one flag (proof#27):
921
- //
922
- // DIRECTORY `logs/<event_id>.json`, each file's RAW BYTES being the
923
- // disclosure. This is the layout `materializeBundle` writes
924
- // (bundle.json + logs/) and the layout the verify page hashes,
925
- // so a pack verifies with NO side-input construction step.
926
- // JSON MAP `{ event_id: <even-length hex, either case | utf8 text> }`, the
927
- // original form. Unchanged.
928
- //
929
- // **Why the directory form exists at all.** Handing a pack to the CLI used to
930
- // require the caller to read `logs/` and hex-encode it into a map, and that
931
- // read-and-encode step has been got wrong twice in shipped code: claims-demo#9
932
- // wrote the hex TEXT into the log files (SHA-256 of hex text is not SHA-256 of
933
- // the bytes it encodes, so genuine evidence failed), and a runbook handed the
934
- // directory straight to `--logs` and got EISDIR, exit 2. The directory form
935
- // removes the encoding decision from the caller entirely — bytes in, bytes
936
- // hashed — so that whole class stops being reachable from this direction.
937
- //
938
- // **Nothing is re-encoded, re-parsed or re-serialised on the way in.** A log
939
- // file's bytes are passed to the verifier as a `Uint8Array`, which the verifier
940
- // uses as-is; only the map form's *strings* go through `decodeLogValue`. That
941
- // is the invariant this feature exists to hold, and it is what makes the CLI
942
- // and the verify page agree about one pack.
828
+ // `--logs` takes a directory of `logs/<event_id>.json` files, each file's raw
829
+ // bytes being the disclosure (the layout `materializeBundle` writes), or a JSON
830
+ // map `{ event_id: <even-length hex, either case | utf8 text> }`. File bytes go
831
+ // to the verifier as-is; only map strings go through `decodeLogValue`.
943
832
  export interface LoadedLogs {
944
- // Keyed by event id. A `Uint8Array` is raw bytes (directory form); a `string`
945
- // is hex in either case (map form). Those are exactly the two the verifier
946
- // distinguishes.
947
- //
948
- // A `Map`, NOT a plain object, and that is load-bearing rather than stylistic.
949
- // An event id is producer-chosen — here it arrives as a FILE NAME or a JSON
950
- // key — and `content["__proto__"] = <bytes>` on an object literal does not
951
- // store anything: it REPLACES the map's prototype with the Uint8Array. The
952
- // disclosed bytes are then silently lost (the holder disclosed them and we
953
- // report the record undisclosed), and every later lookup on that map resolves
954
- // through `Uint8Array.prototype`, so an event named `slice` or `set` finds an
955
- // inherited function and an event named `byteLength` or `buffer` THROWS a
956
- // TypeError out of the verifier. A Map keys on the string itself and has none
957
- // of that.
833
+ // By event id: `Uint8Array` is raw bytes (directory), `string` is hex (map).
834
+ // A `Map`, not an object: an event id is producer-chosen, and
835
+ // `obj["__proto__"] = bytes` replaces the prototype instead of storing the
836
+ // disclosure, after which lookups resolve through `Uint8Array.prototype` and
837
+ // can throw.
958
838
  content: Map<string, Uint8Array | string>;
959
- // Event ids the side input actually supplied — for the post-verify coverage
960
- // cross-check, which needs to know what was offered, not just what bound.
839
+ // Event ids the side input supplied, for the post-verify coverage check.
961
840
  suppliedIds: string[];
962
- // Advisory lines about the INPUT ITSELF (skipped entries, a directory that
963
- // disclosed nothing). None of them moves a verdict or an exit code.
841
+ // Advisory lines about the input itself. None moves a verdict or exit code.
964
842
  notes: string[];
965
843
  }
966
844
 
967
- // The suffix the directory form reads, matched exactly — lowercase, which is
968
- // what `materializeBundle` writes. Anything else is skipped and NAMED rather
969
- // than guessed at, so a `.JSON` or extensionless file shows up as a note
970
- // instead of a mysteriously undisclosed event.
845
+ // Matched exactly, lowercase. Anything else is skipped and named in a note.
971
846
  const LOG_SUFFIX = ".json";
972
847
 
973
- // Which shape was passed is decided by `stat`, never by the extension: a pack's
974
- // log directory can be called anything, and `logs.json` is a legal directory
975
- // name. `stat` follows symlinks, so a symlinked logs directory works.
848
+ // The shape is decided by `stat`, never by extension. `stat` follows symlinks,
849
+ // so a symlinked logs directory works.
976
850
  async function loadLogs(logsPath: string, io: Cli): Promise<LoadedLogs | undefined> {
977
851
  let isDirectory: boolean;
978
852
  try {
@@ -984,7 +858,7 @@ async function loadLogs(logsPath: string, io: Cli): Promise<LoadedLogs | undefin
984
858
  return isDirectory ? loadLogsDirectory(logsPath, io) : loadLogsMap(logsPath, io);
985
859
  }
986
860
 
987
- // The JSON-map form, unchanged: `{ event_id: <hex | utf8 text> }`.
861
+ // The JSON-map form: `{ event_id: <hex | utf8 text> }`.
988
862
  async function loadLogsMap(logsPath: string, io: Cli): Promise<LoadedLogs | undefined> {
989
863
  let logsRaw: string;
990
864
  try {
@@ -998,8 +872,7 @@ async function loadLogsMap(logsPath: string, io: Cli): Promise<LoadedLogs | unde
998
872
  logsParsed = parseRefusingDuplicates(logsRaw);
999
873
  } catch (e) {
1000
874
  if (e instanceof DuplicateKeyError) {
1001
- // One event id named twice hands the reader two contents for one
1002
- // record, and the last one silently won (proof#144).
875
+ // A repeated event id would hand the reader two contents for one record.
1003
876
  io.err(`--logs ${logsPath} ${NOT_I_JSON}: ${e.message}`);
1004
877
  return undefined;
1005
878
  }
@@ -1025,8 +898,7 @@ async function loadLogsMap(logsPath: string, io: Cli): Promise<LoadedLogs | unde
1025
898
  content.set(k, decodeLogValue(v));
1026
899
  suppliedIds.push(k);
1027
900
  }
1028
- // Named, every name, in the one sentence the Python command writes (A42): a
1029
- // value that is not a string is never coerced and never dropped unnamed.
901
+ // Every non-string value is named, never coerced or dropped.
1030
902
  const notes =
1031
903
  notStrings.length === 0
1032
904
  ? []
@@ -1037,53 +909,24 @@ async function loadLogsMap(logsPath: string, io: Cli): Promise<LoadedLogs | unde
1037
909
  return { content, suppliedIds, notes };
1038
910
  }
1039
911
 
1040
- // The directory form, read by the one rule both kernels apply
1041
- // (evidence-bundle.md §5.1.4, A42). The twin is `_load_logs_directory` in
1042
- // `src/sanning_proof/cli.py`, and `cross-kernel/logs_folder_cli_leg.py` holds
1043
- // the two commands to the same stdout, stderr and exit code.
1044
- //
1045
- // A regular file named `<event_id>.json` is that record's disclosed bytes, and
1046
- // nothing else in the folder is read. None of the following is a verification
1047
- // failure, because an absent or unusable disclosure is UNDISCLOSED and absence
1048
- // is not evidence of tampering (evidence-bundle.md §5.1), and every one of them
1049
- // is NAMED in a note, every name, so no file is dropped without being named:
1050
- //
1051
- // - a file whose name is not a known event id → loaded, binds to nothing,
1052
- // named in a post-verify note
1053
- // - an event with no corresponding file → undetermined (`~`), noted
1054
- // - an empty directory → valid; discloses nothing,
1055
- // noted, exit code unchanged
1056
- // - a subdirectory → never entered, noted (the
1057
- // "you pointed at the pack root, not its logs/" case)
1058
- // - a symbolic link → never followed, whatever it
1059
- // points at, noted; `case` never follows one under a pack's `logs/` and
1060
- // `--files` never follows one in a folder (A40), so a link named
1061
- // `<event_id>.json` discloses nothing here either
1062
- // - anything that is not a regular file → never opened, noted
1063
- // - anything else not named `<event_id>.json` → never read, noted
912
+ // The directory form (evidence-bundle.md §5.1.4; Python's twin is
913
+ // `_load_logs_directory`). Only a regular file named `<event_id>.json` is read:
914
+ // links are never followed and subdirectories never entered. Everything else,
915
+ // and every event with no file, is named in a note, because an absent or
916
+ // unusable disclosure is undisclosed, not a failure (§5.1).
1064
917
  //
1065
- // The Python command read every regular file by its STEM until 0.17.0, so a
1066
- // forged `<id>.txt` beside a genuine `<id>.json` failed the pack there and was
1067
- // never read here, and the forged-`.json` direction read clean there and
1068
- // failed here: whoever assembled the folder chose the verdict.
1069
- //
1070
- // The hard stops (exit 2): a `<event_id>.json` file that cannot be read (we were
1071
- // pointed at it, so a permissions error must not read as minimal disclosure),
1072
- // more than MAX_WALK_ENTRIES names, a name that is not valid UTF-8 (escaping it
1073
- // would let two names read as one), and `<event_id>.json` files holding more
1074
- // than MAX_LOGS_BYTES in all.
918
+ // Hard stops (exit 4): an unreadable `<event_id>.json` (a permissions error must
919
+ // not read as minimal disclosure), more than MAX_WALK_ENTRIES names, a name that
920
+ // is not valid UTF-8, or more than MAX_LOGS_BYTES in all.
1075
921
  export const MAX_LOGS_BYTES = MAX_ZIP_BYTES;
1076
922
 
1077
923
  export class LogsTooLarge extends Error {}
1078
924
 
1079
- // A `<event_id>.json` file's bytes. Opened without following a link and without
1080
- // blocking, then checked again, so a file swapped for a link or a pipe after the
1081
- // folder was listed is refused rather than followed or waited on; and read no
1082
- // further than `budget` allows. Two checks, and each is reachable without the
1083
- // other: the size `fstat` reports refuses a sparse file before a byte is read,
1084
- // and the count of bytes read refuses a file that reports less than it holds
1085
- // (a file in /proc reports 0) or grows while it is read. `opener` is for the
1086
- // tests, which must reach the second check with the first one passing.
925
+ // A `<event_id>.json` file's bytes, opened without following a link or
926
+ // blocking and then rechecked, so a swapped-in link or pipe is refused. Two
927
+ // independent size checks: `fstat` refuses a sparse file before reading, and
928
+ // the byte count refuses a file that under-reports (as /proc does) or grows.
929
+ // `opener` lets tests reach the second check.
1087
930
  export async function readLogFile(
1088
931
  path: string,
1089
932
  budget: { n: number },
@@ -1114,8 +957,7 @@ export async function readLogFile(
1114
957
  }
1115
958
  }
1116
959
 
1117
- // `limits` is for the tests, which cannot write a gigabyte or fifty thousand
1118
- // names per case; the command always passes the defaults.
960
+ // `limits` is for tests; the command always passes the defaults.
1119
961
  export async function loadLogsDirectory(
1120
962
  dirPath: string,
1121
963
  io: Cli,
@@ -1154,7 +996,7 @@ export async function loadLogsDirectory(
1154
996
  const p = childPath(dirPath, name);
1155
997
  let s;
1156
998
  try {
1157
- // `lstat`, never `stat`: a link in the folder is never followed.
999
+ // `lstat`: a link in the folder is never followed.
1158
1000
  s = await lstat(p);
1159
1001
  } catch {
1160
1002
  io.err(`cannot read --logs entry ${p}`);
@@ -1172,15 +1014,13 @@ export async function loadLogsDirectory(
1172
1014
  notRegular.push(name);
1173
1015
  continue;
1174
1016
  }
1175
- // Not named `<event_id>.json`, including a file named exactly `.json`,
1176
- // whose event id would be the empty string.
1017
+ // Includes a file named exactly `.json`, whose event id would be empty.
1177
1018
  if (!name.endsWith(LOG_SUFFIX) || name.length === LOG_SUFFIX.length) {
1178
1019
  notNamed.push(name);
1179
1020
  continue;
1180
1021
  }
1181
1022
  const eventId = name.slice(0, -LOG_SUFFIX.length);
1182
1023
  try {
1183
- // Bytes, exactly as they sit on disk.
1184
1024
  content.set(eventId, await readLogFile(p, budget));
1185
1025
  } catch (e) {
1186
1026
  if (e instanceof LogsTooLarge) {
@@ -1200,7 +1040,7 @@ export async function loadLogsDirectory(
1200
1040
  if (dirs.length > 0) {
1201
1041
  notes.push(
1202
1042
  `--logs ${dirPath}: skipped ${count(dirs.length, "subdirectory", "subdirectories")} ` +
1203
- `(${quoted(dirs)}) — the directory form reads <event_id>${LOG_SUFFIX} files ` +
1043
+ `(${quoted(dirs)}); the directory form reads <event_id>${LOG_SUFFIX} files ` +
1204
1044
  `and does not recurse`,
1205
1045
  );
1206
1046
  }
@@ -1227,24 +1067,20 @@ export async function loadLogsDirectory(
1227
1067
  }
1228
1068
  if (suppliedIds.length === 0) {
1229
1069
  notes.push(
1230
- `--logs ${dirPath} disclosed nothing (no <event_id>${LOG_SUFFIX} files) — every event ` +
1231
- `stays undisclosed, which is undetermined, not failed`,
1070
+ `--logs ${dirPath} disclosed nothing (no <event_id>${LOG_SUFFIX} files); every event ` +
1071
+ `stays undisclosed, which is not checked, not failed`,
1232
1072
  );
1233
1073
  }
1234
1074
  return { content, suppliedIds, notes };
1235
1075
  }
1236
1076
 
1237
- // What the side input covered, reported once the bundle's own event ids are
1238
- // known. Both directions are ADVISORY — neither moves the verdict or the exit
1239
- // code — and both exist because the silent version of each has already cost
1240
- // somebody an afternoon:
1077
+ // What the side input covered, once the bundle's event ids are known. Advisory
1078
+ // only:
1241
1079
  //
1242
- // - an entry matching no event binds to nothing. A typo'd id, or a directory
1243
- // scoped to a different bundle, otherwise looks identical to minimal
1244
- // disclosure.
1245
- // - an event whose content stayed undetermined disclosed no usable bytes.
1246
- // That is UNDISCLOSED, never a failure (evidence-bundle.md §5.1): absence
1247
- // of a disclosure is not evidence of tampering.
1080
+ // - an entry matching no event binds to nothing (a typo or the wrong
1081
+ // directory otherwise looks like minimal disclosure).
1082
+ // - an event whose content stayed undetermined disclosed no usable bytes:
1083
+ // undisclosed, never a failure (evidence-bundle.md §5.1).
1248
1084
  function reportLogsCoverage(logs: LoadedLogs, result: EvidenceBundleResult, io: Cli): void {
1249
1085
  const eventIds = new Set(result.events.map((e) => e.eventId));
1250
1086
  const unmatched = logs.suppliedIds.filter((id) => !eventIds.has(id)).sort();
@@ -1254,13 +1090,13 @@ function reportLogsCoverage(logs: LoadedLogs, result: EvidenceBundleResult, io:
1254
1090
  `in the bundle, ignored: ${quoted(unmatched)}`,
1255
1091
  );
1256
1092
  }
1257
- const undetermined = result.events.filter((e) => e.contentOk === null);
1258
- if (undetermined.length > 0) {
1093
+ const unchecked = result.events.filter((e) => e.contentOk === null);
1094
+ if (unchecked.length > 0) {
1259
1095
  io.err(
1260
- `note: ${undetermined.length} of ${result.events.length} event(s) have undetermined ` +
1261
- `content: no bytes disclosed, or no committed content_hash (message_hash for a ` +
1096
+ `note: ${unchecked.length} of ${result.events.length} event(s) have content that ` +
1097
+ `was not checked: no bytes disclosed, or no committed content_hash (message_hash for a ` +
1262
1098
  `hand-off record) to bind them to. ` +
1263
- `Undisclosed, not failed: ${sample(undetermined.map((e) => e.eventId))}`,
1099
+ `Undisclosed, not failed: ${sample(unchecked.map((e) => e.eventId))}`,
1264
1100
  );
1265
1101
  }
1266
1102
  }
@@ -1273,43 +1109,24 @@ function count(n: number, one: string, many: string): string {
1273
1109
  return `${n} ${n === 1 ? one : many}`;
1274
1110
  }
1275
1111
 
1276
- // Every name, quoted. Every one, not the first few: a file left out of a note
1277
- // is a file dropped without being named.
1112
+ // Every name, quoted: a file left out of a note is a file dropped unnamed.
1278
1113
  function quoted(names: string[]): string {
1279
1114
  return names.map((n) => JSON.stringify(n)).join(", ");
1280
1115
  }
1281
1116
 
1282
- // First few names, quoted, with a tail count — enough to recognise the mistake
1283
- // without pasting a whole directory into the terminal.
1117
+ // The first few names, quoted, with a count of the rest.
1284
1118
  function sample(names: string[], max = 3): string {
1285
1119
  const shown = names.slice(0, max).map((n) => JSON.stringify(n)).join(", ");
1286
1120
  return names.length > max ? `${shown}, +${names.length - max} more` : shown;
1287
1121
  }
1288
1122
 
1289
- // The --logs MAP lane decides each disclosed value's encoding: a clean,
1290
- // even-length hex string is hex bytes; anything else is utf8 text. (The
1291
- // verifier itself only distinguishes "string ⇒ hex" from "Uint8Array ⇒ raw
1292
- // bytes", so the human-facing encoding choice is made here. The DIRECTORY lane
1293
- // never reaches this: its bytes are already bytes.)
1294
- //
1295
- // EITHER CASE, through the one charset rule (proof#146). This test was
1296
- // `/^[0-9a-f]+$/`, lower case only, and it was the last copy of the hex rule
1297
- // in either kernel that answered differently from the others: the Python CLI
1298
- // passes every string to the kernel, and the kernel decodes it with the
1299
- // case-open `hexToBytes` on BOTH sides. So a disclosure map written in
1300
- // upper-case hex was read as 4 bytes of evidence there and as 8 bytes of TEXT
1301
- // here, and this command told the auditor their genuine, correctly disclosed
1302
- // evidence had FAILED: `proof verify --logs map.json` exited 3 in Python and
1303
- // 1 here, on one pack and one map. Telling an auditor their evidence is forged
1304
- // is the worst direction to be wrong in, which is the argument the Python
1305
- // twin's own docstring already makes about this exact lane.
1306
- //
1307
- // Pinned by `cross-kernel/run_cli.sh`'s `logs/hex-map-upper`, beside the
1308
- // lowercase lane that existed before and only ever exercised the one spelling
1309
- // the two commands happened to agree on.
1123
+ // The map form's encoding: a non-empty, even-length hex string (either case,
1124
+ // through the shared charset rule) is hex; anything else is UTF-8 text. Python
1125
+ // decodes the same way, so a map in upper-case hex is evidence in both
1126
+ // kernels, never text in one and a failure in the other.
1310
1127
  function decodeLogValue(v: string): Uint8Array | string {
1311
- if (v.length > 0 && v.length % 2 === 0 && isHexField(v, v.length)) return v; // hex passthrough
1312
- return utf8(v); // raw utf8 bytes
1128
+ if (v.length > 0 && v.length % 2 === 0 && isHexField(v, v.length)) return v;
1129
+ return utf8(v);
1313
1130
  }
1314
1131
 
1315
1132
  function printUsage(io: Cli): void {
@@ -1327,10 +1144,10 @@ function printUsage(io: Cli): void {
1327
1144
  io.out("");
1328
1145
  io.out("Verify a sanning.evidence/v1 bundle (body sanning.anchor.trace/v3,");
1329
1146
  io.out("sanning.stamp.trace/v1, sanning.stamp.evidence/v2 or the attested export");
1330
- io.out("sanning.evidence.export/v2) or a sanning.agent.proof/v1 inclusion bundle,");
1147
+ io.out("sanning.evidence.export/v2),");
1331
1148
  io.out("fully offline. Pass --gateways <url,url> to also re-fetch each");
1332
1149
  io.out("checkpoint and stamp on-chain. Only arweave witnesses are re-fetched;");
1333
- io.out("a witness of any other kind is not checked and the run exits 3.");
1150
+ io.out("a witness of any other kind is not checked, and the witness line says so.");
1334
1151
  io.out("");
1335
1152
  io.out("--json Print the verdict, the causes and the exit code as");
1336
1153
  io.out(" JSON, for a script, instead of the report.");
@@ -1354,9 +1171,9 @@ function printUsage(io: Cli): void {
1354
1171
  io.out("--logs <path> Bind disclosed raw logs to each event's committed");
1355
1172
  io.out(" content_hash, or message_hash for a hand-off record.");
1356
1173
  io.out(" Either shape works, and which one you");
1357
- io.out(" passed is decided by stat(), not by the extension:");
1174
+ io.out(" passed is determined by stat(), not by the extension:");
1358
1175
  io.out("");
1359
- io.out(" a DIRECTORY of <event_id>.json files — the file's");
1176
+ io.out(" a DIRECTORY of <event_id>.json files; the file's");
1360
1177
  io.out(" raw bytes ARE the content, nothing is decoded.");
1361
1178
  io.out(" This is a materialized pack's logs/ directory:");
1362
1179
  io.out(" sanning-proof verify <pack>/bundle.json --logs <pack>/logs");
@@ -1370,36 +1187,27 @@ function printUsage(io: Cli): void {
1370
1187
  io.out("");
1371
1188
  io.out(" In-body events[].content is verified automatically");
1372
1189
  io.out(" and takes precedence. An event with no disclosed");
1373
- io.out(" bytes is undetermined (~), never a failure.");
1190
+ io.out(" bytes is not checked, never a failure.");
1374
1191
  io.out("");
1375
- io.out("The headline is the PACK verdict, the same one the verify page prints:");
1376
- io.out("a record nobody hashed leaves the pack INCOMPLETE even when the signed");
1377
- io.out("bundle itself is verified. Both verdicts are printed when they differ.");
1192
+ io.out("The headline is the pack verdict, the same one the verify page prints, read");
1193
+ io.out("off six claims: signed, unchanged, witnessed, complete, agreed and follows the");
1194
+ io.out("rules. A record nobody hashed leaves the pack incomplete even when the signed");
1195
+ io.out("bundle itself verifies. The report prints one line for each claim that is");
1196
+ io.out("disproven or not proven, and one next action.");
1378
1197
  io.out("");
1379
1198
  for (const line of EXIT_CODES_HELP.split("\n")) io.out(line);
1380
1199
  io.out("");
1381
- io.out("verify: an unreachable gateway reads partial, and a pack with a record");
1382
- io.out("nobody hashed reads incomplete; both exit 3, and neither is a failure.");
1200
+ io.out("verify: a pack with a record nobody hashed reads incomplete and exits 2, which");
1201
+ io.out("is not a failure. A gateway that cannot be reached moves nothing: the witness");
1202
+ io.out("line says so.");
1383
1203
  }
1384
1204
 
1385
1205
  /**
1386
- * The disclosure account this command can honestly make, from what the VERIFIER
1387
- * determined rather than from what the loader happened to read.
1388
- *
1389
- * `accountDisclosure` is offered by `grading.ts` as a convenience and is
1390
- * explicitly "NOT THE ONLY WAY TO BUILD AN ACCOUNT". It reads the side map
1391
- * only, and this command has a second disclosure lane the side map cannot see:
1392
- * `events[].content`, carried INSIDE the bundle and bound by the verifier
1393
- * without any `--logs` at all. Accounting off the side map would file every
1394
- * in-body disclosure as `missing` and print a shortfall the pack does not have,
1395
- * which is the shape of manufactured accusation `grading.ts` keeps warning
1396
- * about.
1397
- *
1398
- * So `present` is "the verifier reached a determination for this record's
1399
- * content" and `missing` is "it could not", which is the same tri-state that
1400
- * decides the verdict, so the causes printed can never contradict the headline.
1401
- * `extra` still comes from the side input, because bytes that bound to nothing
1402
- * are a fact about the pack that only the loader knows.
1206
+ * The disclosure account, from what the verifier determined rather than what
1207
+ * the loader read. `accountDisclosure` sees only the side map and would file
1208
+ * every in-body `events[].content` disclosure as missing. Here `present` and
1209
+ * `missing` follow the same tri-state as the verdict, so the causes printed
1210
+ * cannot contradict the headline; `extra` comes from the side input.
1403
1211
  */
1404
1212
  function cliDisclosureAccount(
1405
1213
  result: EvidenceBundleResult,
@@ -1411,14 +1219,63 @@ function cliDisclosureAccount(
1411
1219
  expected,
1412
1220
  present: result.events.filter((e) => e.contentOk !== null).map((e) => e.eventId),
1413
1221
  missing: result.events.filter((e) => e.contentOk === null).map((e) => e.eventId),
1414
- // The loader hard-stops (exit 2) on a file it selected and could not read,
1415
- // and NAMES everything it skipped as a note, so nothing reaches here as an
1416
- // unusable disclosure bound to a named record.
1222
+ // The loader hard-stops on an unreadable selected file and names every
1223
+ // skip, so nothing unusable reaches here.
1417
1224
  unreadable: [],
1418
1225
  extra: (logs?.suppliedIds ?? []).filter((id) => !known.has(id)),
1419
1226
  };
1420
1227
  }
1421
1228
 
1229
+ // The chain reading could not be produced, which is not a reading of nothing.
1230
+ function chainsUnreadableText(reason: string): string {
1231
+ return (
1232
+ "this kernel could not read the event chains in this bundle " +
1233
+ `(${reason}), so the chain evidence is unknown here. That is ` +
1234
+ "not the same as there being none to read."
1235
+ );
1236
+ }
1237
+
1238
+ /**
1239
+ * One note per record-layer finding (agent-events.md section 10.1), in the order each
1240
+ * is first met, counted over the records that carry it, its kind the finding's name. A
1241
+ * finding never moves a verdict.
1242
+ */
1243
+ function findingNotes(readings: Iterable<AgentRecordReading | null>): NoteEntry[] {
1244
+ const counts = new Map<string, number>();
1245
+ for (const reading of readings) {
1246
+ if (reading === null) continue;
1247
+ for (const finding of new Set(reading.findings)) {
1248
+ counts.set(finding, (counts.get(finding) ?? 0) + 1);
1249
+ }
1250
+ }
1251
+ return [...counts].map(([f, n]) => note(f, renderFindingNote(f, n)));
1252
+ }
1253
+
1254
+ /** The witness line without its `Witness:` label, which the person layer prints. */
1255
+ function witnessText(line: WitnessLine): string {
1256
+ return renderWitnessLine(line).slice("Witness: ".length);
1257
+ }
1258
+
1259
+ /**
1260
+ * The witness line of one pack read by `verify`: the lookup `verifyEvidenceBundle` ran
1261
+ * with the gateways, in the words the case reading uses.
1262
+ */
1263
+ function verifyWitness(result: EvidenceBundleResult, gateways: readonly string[]): WitnessLine {
1264
+ // No lookup ran: none was asked for, or the run read no witness to look up (a wrapper that
1265
+ // does not hold, a body this kernel does not read).
1266
+ if (gateways.length === 0 || result.checkpoints.length + (result.stamps ?? []).length === 0) {
1267
+ return witnessLine({ checked: false, gateways: [], packs: [], verdict: "not_checked" });
1268
+ }
1269
+ const { row, answered } = onChainRow("", result);
1270
+ return witnessLine({
1271
+ answered: gateways.filter((g) => answered.has(g)),
1272
+ checked: true,
1273
+ gateways: [...gateways],
1274
+ packs: [row],
1275
+ verdict: onChainOutcome([row]),
1276
+ });
1277
+ }
1278
+
1422
1279
  function reportEvidence(
1423
1280
  result: EvidenceBundleResult,
1424
1281
  path: string,
@@ -1429,486 +1286,302 @@ function reportEvidence(
1429
1286
  parsed: unknown,
1430
1287
  logs: LoadedLogs | undefined,
1431
1288
  sink: JsonSink | null,
1289
+ bare: EvidenceBundleResult | null = null,
1432
1290
  ): number {
1433
- if (result.status === "malformed") {
1434
- if (sink) sink.data = verifyJson(result, null, null, null);
1435
- io.err(paint("MALFORMED", R) + ` ${path}`);
1436
- for (const e of result.errors) io.err(` - ${e}`);
1437
- return EXIT_MALFORMED;
1438
- }
1439
-
1440
- io.out(paint("Evidence bundle", B) + ` ${path}`);
1441
- io.out(` body_type ${result.bodyType ?? "<unknown>"}`);
1442
- io.out(
1443
- ` wrapper sig ${mark(result.signatureOk)} body_hash ${mark(result.bodyHashOk)} spec ${mark(result.specVersionOk)}`,
1291
+ // What the pack says it holds, against what it does (evidence-bundle.md section
1292
+ // 5.4). A pack with no declaration leaves `complete` out of scope, and a note says
1293
+ // so. A pack that carries a declaration this reader cannot read is incomplete: the
1294
+ // holder chose that field, so unknown is the honest answer. A v3 chain claim is
1295
+ // checked against this command's links (rule 11); unreadable links read the claim
1296
+ // unknown.
1297
+ const declaration = checkDeclaration(parsed, logs?.content, links);
1298
+ const grade = gradePack(
1299
+ result,
1300
+ cliDisclosureAccount(result, logs),
1301
+ completenessNotes(parsed),
1302
+ declaration,
1444
1303
  );
1445
1304
 
1305
+ // Sandbox evidence: read from the signed envelopes the pack holds. A label, never a
1306
+ // verdict: a sandbox pack that verifies exits 3.
1307
+ const marker = environmentMarker([parsed]);
1308
+ const agents = result.events.filter((ev) => ev.agent).map((ev) => ev.agent);
1309
+ const inScope: string[] = [];
1310
+ const notInScope: Partial<Record<Claim, NotInScopeReason>> = { agreed: "no_handoff" };
1311
+ if (declaration.present) inScope.push("complete");
1312
+ else notInScope.complete = "no_declaration";
1313
+ if (agents.length > 0) inScope.push("follows_rules");
1314
+ else notInScope.follows_rules = "no_agent_records";
1315
+ let problems = [...grade.problems];
1316
+ if (result.unreadable === true) {
1317
+ // A pack this reader cannot read says nothing about the bytes beside it: its own
1318
+ // problem is the one line, as `case` reads it.
1319
+ problems = bundleProblems(result, 0);
1320
+ } else if (bare !== null && grade.verdict === "failed") {
1321
+ // No disproof rests on bytes nobody signed: `bare` is the same pack read without the
1322
+ // bytes handed in beside it, as `case` reads it.
1323
+ const signed = gradePack(
1324
+ bare,
1325
+ cliDisclosureAccount(bare, undefined),
1326
+ completenessNotes(parsed),
1327
+ checkDeclaration(parsed, undefined, links),
1328
+ );
1329
+ problems = restOnSigned(problems, signed.problems);
1330
+ }
1331
+ const eventIds = result.events.map((ev) => ev.eventId);
1332
+ const claims = claimsOf(problems, inScope, {
1333
+ notInScope,
1334
+ labels: (_pack, record) => [null, record !== null ? (eventIds[record] ?? null) : null],
1335
+ });
1336
+ const verdict = verdictOf(claims);
1337
+ const exit = exitCodeFor(verdict.status, marker);
1338
+
1339
+ const unreadable = "unreadable" in chains ? chains.unreadable : null;
1340
+ const summaries = "unreadable" in chains ? [] : chains.chains;
1341
+ const findings = "unreadable" in chains ? [] : chains.findings;
1342
+ const notes = (result.notes ?? []).map((n) => note("co_signatures_present", n));
1343
+ if (!declaration.present && result.unreadable !== true) {
1344
+ notes.push(note("pack_undeclared", renderDeclarationSummary(declaration)));
1345
+ }
1346
+ notes.push(...declaration.notes.map((n) => note(n.kind, renderNote(n))));
1347
+ if (unreadable !== null) notes.push(note("chains_unreadable", chainsUnreadableText(unreadable)));
1348
+ notes.push(...findings.map((f) => note(f.kind, renderChainFinding(f, "this bundle"))));
1349
+ notes.push(...findingNotes(agents));
1350
+ const witness = verifyWitness(result, gateways);
1351
+ const person = renderPerson(verdict, claims, orderedNotes(notes), marker, witnessText(witness), witness.state);
1352
+
1353
+ for (const line of personText(person)) io.out(line);
1354
+ io.out("");
1355
+ io.out(`Evidence bundle ${path}`);
1356
+ io.out(` body_type ${result.bodyType || "<unknown>"}`);
1357
+ // Both verdicts, whenever they differ: a pack can be incomplete over an impeccable
1358
+ // bundle, and the headline alone would read it as broken.
1359
+ if (verdict.status !== result.status) {
1360
+ io.out(
1361
+ ` bundle the signed bundle is "${result.status}"; the pack ` +
1362
+ `around it is "${verdict.status}"`,
1363
+ );
1364
+ }
1365
+
1446
1366
  if (result.checkpoints.length > 0) {
1447
1367
  io.out("");
1448
- io.out(paint(" Checkpoints", DIM));
1368
+ io.out(" Checkpoints");
1449
1369
  for (const cp of result.checkpoints) {
1450
- const onChain = cp.onChainOk === null ? "" : ` on-chain ${mark(cp.onChainOk)}`;
1451
- io.out(
1452
- ` ${mark(cp.ok)} ${shortTx(cp.checkpointRef)} envelope ${mark(cp.envelopeOk)} root ${mark(cp.merkleRootOk)}${onChain}`,
1453
- );
1454
- for (const e of cp.errors) io.out(paint(` - ${e}`, R));
1370
+ io.out(` ${cp.ok ? "OK" : "XX"} ${cp.checkpointRef || "<no ref>"}`);
1371
+ for (const e of cp.errors) io.out(` - ${e}`);
1455
1372
  }
1456
1373
  }
1457
1374
 
1458
- // Stamps (evidence-bundle.md §5.2 / §5.3). Printed rather than summarised,
1459
- // because for a stamp body the WITNESS is the entire independent part
1460
- // (§5.2.4) — and until now this reporter showed nothing about it at all, so a
1461
- // §5.3 pack printed a rollup line claiming an on-chain scope with no visible
1462
- // on-chain result anywhere above it.
1463
- //
1464
- // The on-chain column distinguishes all four states in the same three
1465
- // characters the checkpoint block uses, plus its absence: `✓` confirmed,
1466
- // `✗` mismatched, `~` asked and unreachable, and NO COLUMN AT ALL when
1467
- // nothing was checked. A reader must never have to infer "not checked" from
1468
- // a symbol that also means "could not be reached".
1469
- if (result.stamps && result.stamps.length > 0) {
1375
+ // The stamps. For a stamp body the witness is the entire independent part (section 5.2.4).
1376
+ // No on-chain column when nothing was checked: "not checked" must not be inferred
1377
+ // from a word that also means "we asked and nobody answered".
1378
+ const stamps = result.stamps ?? [];
1379
+ if (stamps.length > 0) {
1470
1380
  io.out("");
1471
- io.out(paint(" Stamps", DIM));
1472
- for (const st of result.stamps) {
1381
+ io.out(" Stamps");
1382
+ for (const st of stamps) {
1473
1383
  const witness =
1474
- st.anchorRef === null
1475
- ? paint("~ no witness", Y)
1476
- : `witness ${st.anchorRef.kind}:${shortTx(st.anchorRef.id)}`;
1477
- const onChain =
1478
- st.onChain === null ? "" : ` on-chain ${mark(st.onChainOk)} (${st.onChain.rollup})`;
1384
+ st.anchorRef === null ? "no witness" : `witness ${st.anchorRef.kind}:${st.anchorRef.id}`;
1385
+ const onChain = st.onChain === null ? "" : ` on-chain ${st.onChain.rollup}`;
1479
1386
  io.out(
1480
- ` ${mark(st.ok)} ${shortTx(st.stampRef)} bytes ${mark(st.stampRefOk && st.bodyOk)}` +
1481
- ` ranges ${mark(st.subtreesOk)} ${witness}${onChain}`,
1387
+ ` ${st.ok ? "OK" : "XX"} ${st.stampRef || "<no stamp_ref>"} ` +
1388
+ `bytes ${st.stampRefOk && st.bodyOk ? "OK" : "XX"} ` +
1389
+ `ranges ${st.subtreesOk ? "OK" : "XX"} ${witness}${onChain}`,
1482
1390
  );
1483
- for (const e of st.errors) io.out(paint(` - ${e}`, R));
1391
+ for (const e of st.errors) io.out(` - ${e}`);
1484
1392
  }
1485
1393
  }
1486
1394
 
1487
1395
  if (result.events.length > 0) {
1488
1396
  io.out("");
1489
- io.out(paint(" Events", DIM));
1397
+ io.out(" Events");
1490
1398
  for (const ev of result.events) {
1491
- // `~` is "not determined", and the words after it must say only that.
1492
- // This read "record withheld", which is a statement about the PRODUCER:
1493
- // it says the customer chose minimum disclosure and kept these bytes
1494
- // back, which is legitimate and expected. An event whose envelope was
1495
- // never an envelope produces the same `null` on this dimension, and
1496
- // there the sentence is simply untrue: the record may be sitting in the
1497
- // bundle, and nothing about the producer has been established either
1498
- // way. A renderer cannot tell the two apart from a tri-state, so it
1499
- // states what the verifier did and leaves WHY to the error lines below,
1500
- // which now name the malformed envelope instead of a signature check
1501
- // that never ran (proof#76).
1502
- const binding =
1503
- ev.payloadBindingOk === null
1504
- ? paint("~ record not checked", Y)
1505
- : `record ${mark(ev.payloadBindingOk)}`;
1506
- // Show the content (logs) segment only when content was disclosed and
1507
- // evaluated (non-null) — undisclosed content is the default and would just
1508
- // be noise on every line.
1509
- const logs = ev.contentOk === null ? "" : ` logs ${mark(ev.contentOk)}`;
1510
- io.out(
1511
- ` ${mark(ev.ok)} ${shortId(ev.eventId)} envelope ${mark(ev.envelopeOk)} ${binding} inclusion ${mark(ev.inclusionOk)}${logs}`,
1512
- );
1513
- for (const e of ev.errors) io.out(paint(` - ${e}`, R));
1399
+ io.out(` ${ev.ok ? "OK" : "XX"} ${ev.eventId || "<no event_id>"}`);
1400
+ for (const e of ev.errors) io.out(` - ${e}`);
1401
+ // The agent reading, with the pack's other records held beside it. Its findings
1402
+ // are notes and move nothing; a reading that is not verified is also a cause
1403
+ // of the pack verdict below.
1404
+ if (ev.agent !== null) io.out(` agent ${renderAgentReading(ev.agent)}`);
1514
1405
  }
1515
1406
  }
1516
1407
 
1517
- reportChains(chains, io);
1518
-
1519
- if (result.export) {
1520
- reportExport(result.export, io);
1521
- }
1522
-
1523
- // What the pack SAYS it holds, against what it does (evidence-bundle.md
1524
- // §5.4). A pack that deviates from its own declaration FAILS here and exits
1525
- // 1, which is the point: every other check in this command is a check on a
1526
- // record that is present, so a trimmed hand-over passes all of them.
1527
- //
1528
- // A pack with NO declaration exits exactly as it did before §5.4. The check
1529
- // answers `partial` and the line below says so in words, and pack grading
1530
- // keeps its own verdict: every pack built before §5.4 carries no
1531
- // declaration, and re-grading them all as undetermined would be a change to
1532
- // their verdicts rather than a new check. A pack that DOES
1533
- // carry a declaration this reader cannot read is a different case and exits
1534
- // 3: the holder chose that field, so UNKNOWN is the honest answer.
1535
- //
1536
- // A v3 declaration that claims a chain is checked against this command's
1537
- // own chain links (rule 11, evidence-bundle.md 5.4.2 v1.36, A45). Links
1538
- // that could not be read are passed as none, which reads the claim UNKNOWN
1539
- // and never clean.
1540
- const declaration = checkDeclaration(parsed, logs?.content, links);
1541
- const grade = gradePack(
1542
- result,
1543
- cliDisclosureAccount(result, logs),
1544
- completenessNotes(parsed),
1545
- declaration,
1546
- );
1547
-
1548
- // Sandbox evidence: read from the signed envelopes the pack holds.
1549
- const marker = environmentMarker([parsed]);
1550
- // A pack whose records are all signed `dev`, undetermined only because a
1551
- // simulated witness cannot be looked up, would grade `verified` with that
1552
- // witness accepted: it is sandbox evidence, exit 5, never 3.
1553
- const simulatedOnly =
1554
- grade.verdict === "partial" &&
1555
- onlySimulatedUnsettled(result) &&
1556
- gradePack(
1557
- { ...result, status: "verified" },
1558
- cliDisclosureAccount(result, logs),
1559
- completenessNotes(parsed),
1560
- declaration,
1561
- ).verdict === "verified";
1562
- const exit = sandboxExit(mapGradeToExit(grade), marker, simulatedOnly);
1408
+ for (const e of result.errors) io.err(` - ${e}`);
1563
1409
 
1410
+ // The chain block: the chains walked (their findings are notes, printed above); no
1411
+ // chain to follow; or a reading that could not be produced, printed as itself.
1564
1412
  io.out("");
1565
- printRollup(
1566
- grade.verdict,
1567
- result.status,
1568
- result.assertedStatus,
1569
- gateways,
1570
- result.onChainChecked,
1571
- io,
1572
- marker,
1573
- exit === EXIT_SANDBOX,
1574
- simulatedOnlyRun(result, gateways),
1575
- );
1576
- io.out(
1577
- paint(
1578
- ` declaration: ${renderDeclarationSummary(declaration)}`,
1579
- declaration.verdict === "failed" ? R : declaration.verdict === "verified" ? DIM : Y,
1580
- ),
1581
- );
1582
- for (const n of declaration.notes) io.out(paint(` note: ${renderNote(n)}`, Y));
1583
- for (const e of result.errors) io.out(paint(` note: ${e}`, DIM));
1584
- // Every reason the pack is not verified, in the kernel's own words. An
1585
- // `incomplete` with no cause is a surface saying "unknown" and unable to say
1586
- // why, which `grading.ts` calls out by name as a defect of its own.
1587
- for (const c of grade.causes) io.out(paint(` - ${renderCause(c, "the pack")}`, Y));
1588
-
1589
- // The fraction describes the PACK, not the disclosure. It used to divide by
1590
- // the number of records that happened to disclose bytes, so a pack that
1591
- // disclosed two of three records printed `logs: 2/2`, a full score for a
1592
- // partial hand-over, and the one line a reader skims for exactly this.
1593
- if (result.events.length > 0) {
1413
+ if (unreadable !== null) {
1414
+ io.out(` Chain ${chainsUnreadableText(unreadable)}`);
1415
+ } else if (summaries.length === 0) {
1594
1416
  io.out(
1595
- paint(
1596
- ` logs: ${grade.recordsVerified}/${result.events.length} record(s) content-verified`,
1597
- DIM,
1598
- ),
1417
+ " Chain no event chain was found to follow in this " +
1418
+ "bundle, so nothing was checked here",
1599
1419
  );
1420
+ } else {
1421
+ io.out(" Event chains");
1422
+ for (const c of summaries) {
1423
+ const where = c.namespace === undefined ? "record" : `${c.namespace} metadata`;
1424
+ io.out(
1425
+ ` ${c.chainKey} ${c.events} record${c.events === 1 ? "" : "s"} present` +
1426
+ ` (linked in ${where})`,
1427
+ );
1428
+ }
1429
+ io.out(` ${CHAIN_READING_SCOPE}`);
1600
1430
  }
1601
1431
 
1602
- if (sink) sink.data = verifyJson(result, grade, declaration, chains, marker);
1603
- return exit;
1604
- }
1605
-
1606
- /**
1607
- * What `verify --json` prints for an evidence bundle, the same keys and the
1608
- * same words as `_verify_json` in the Python twin. `grade`, `declaration` and
1609
- * `chains` are null for a bundle no verdict could be rendered over.
1610
- */
1611
- function verifyJson(
1612
- result: EvidenceBundleResult,
1613
- grade: PackGrade | null,
1614
- declaration: ReturnType<typeof checkDeclaration> | null,
1615
- chains: ChainOutcome | null,
1616
- marker: EnvironmentMarker | null = null,
1617
- ): Record<string, unknown> {
1618
- let chainBlock: Record<string, unknown> | null = null;
1619
- if (chains !== null) {
1620
- chainBlock =
1621
- "unreadable" in chains
1622
- ? { unreadable: chains.unreadable, chains: 0, findings: [] }
1623
- : {
1624
- unreadable: null,
1625
- chains: chains.chains.length,
1626
- findings: chains.findings.map((f) => renderChainFinding(f, "this bundle")),
1627
- };
1628
- }
1629
- return {
1630
- verdict: grade ? grade.verdict : result.status,
1631
- bundle_status: result.status,
1632
- asserted_status: result.assertedStatus ?? null,
1633
- body_type: result.bodyType ?? null,
1634
- on_chain_checked: result.onChainChecked,
1635
- declaration: declaration ? declaration.verdict : null,
1636
- declaration_notes: declaration ? declaration.notes.map(renderNote) : [],
1637
- causes: grade ? grade.causes.map((c) => renderCause(c, "the pack")) : [],
1638
- notes: [...result.errors],
1639
- records: grade
1640
- ? { presented: result.events.length, content_verified: grade.recordsVerified }
1641
- : null,
1642
- chains: chainBlock,
1643
- // Present only where the pack holds an envelope signed `dev`
1644
- // (envelope-spec §2 MAY), so a production pack's JSON is unchanged.
1645
- ...(marker !== null ? { environment_marker: marker } : {}),
1646
- };
1647
- }
1648
-
1649
- /**
1650
- * The chain block: what following each record's pointer to the record before it
1651
- * showed, over the records THIS bundle presents.
1652
- *
1653
- * Three deliberate choices, all of them about not overclaiming.
1654
- *
1655
- * 1. It prints the chains it READ, not just the findings. "3 chains, nothing to
1656
- * report" and silence are different statements, and a reader that quietly
1657
- * matched no records at all would produce the second one while covering
1658
- * nothing.
1659
- * 2. It prints the scope sentence whenever it read a chain, findings or not.
1660
- * "Delete an event and the next pointer dangles" is true; "so the bundle is
1661
- * complete" does not follow, and two customer-facing documents had already
1662
- * made that leap. The limit travels with the result or it gets dropped.
1663
- * 3. It does not touch the exit code. Findings are evidence; the exit code is
1664
- * the verdict surface, and the one exit-code table (`./exit-codes.ts`) reads
1665
- * the verdict and nothing else. A dangling link is something for a person to look at, not a machine
1666
- * decision this check is entitled to make.
1667
- */
1668
- function reportChains(reading: ChainOutcome, io: Cli): void {
1669
- if ("unreadable" in reading) {
1670
- io.out("");
1432
+ if (simulatedOnlyRun(result, gateways)) {
1671
1433
  io.out(
1672
- paint(
1673
- ` Event chains this kernel could not read the event chains in this bundle ` +
1674
- `(${reading.unreadable}), so the chain evidence is UNKNOWN here. That is not ` +
1675
- `the same as there being none to read.`,
1676
- Y,
1677
- ),
1434
+ ` Gateways ${gateways.join(", ")}; a simulated witness is not ` +
1435
+ "on a chain, so nothing was looked up",
1678
1436
  );
1679
- return;
1437
+ } else if (gateways.length > 0) {
1438
+ io.out(` Gateways ${gateways.join(", ")}`);
1439
+ } else {
1440
+ io.out(" Gateways none supplied; the on-chain half was not checked");
1680
1441
  }
1681
- if (reading.chains.length === 0) return;
1682
1442
 
1683
- io.out("");
1684
- io.out(paint(" Event chains", DIM));
1685
- for (const c of reading.chains) {
1686
- const where = c.namespace === undefined ? "record" : `${c.namespace} metadata`;
1443
+ io.out(` declaration ${renderDeclarationSummary(declaration)}`);
1444
+
1445
+ // Every reason the pack is not verified, in the kernel's own words.
1446
+ for (const c of grade.causes) io.out(` - ${renderCause(c, "the pack")}`);
1447
+
1448
+ // The fraction describes the pack, not the disclosure.
1449
+ if (result.events.length > 0) {
1687
1450
  io.out(
1688
- paint(
1689
- ` ${c.chainKey} ${count(c.events, "record", "records")} present (linked in ${where})`,
1690
- DIM,
1691
- ),
1451
+ ` logs ${grade.recordsVerified}/${result.events.length} ` +
1452
+ "record(s) content-verified",
1692
1453
  );
1693
1454
  }
1694
- for (const f of reading.findings) {
1695
- io.out(paint(` ! ${renderChainFinding(f, "this bundle")}`, Y));
1696
- }
1697
- io.out(paint(` ${CHAIN_READING_SCOPE}`, DIM));
1698
- }
1699
1455
 
1700
- // Render the export-specific block (evidence-export.md §5): source-bundle
1701
- // linkage, cached-vs-recomputed verdict agreement, and each embedded operator
1702
- // attestation (RSA-PSS sig · operator-address binding · data_hash binding).
1703
- function reportExport(exp: ExportResult, io: Cli): void {
1704
- io.out("");
1705
- io.out(paint(" Attested export", DIM));
1706
- io.out(
1707
- ` source linkage ${mark(exp.sourceLinkageOk)} verdict agreement ${mark(exp.verdictAgreementOk)}`,
1708
- );
1709
- if (exp.attestations.length > 0) {
1710
- io.out(paint(" Operator attestations", DIM));
1711
- for (const a of exp.attestations) {
1712
- const subj = a.subjectRefOk === null ? "" : ` subject ${mark(a.subjectRefOk)}`;
1713
- io.out(
1714
- ` ${mark(a.ok)} ${shortId(a.operator)} sig ${mark(a.signatureOk)}` +
1715
- ` operator ${mark(a.operatorAddressBound)} data_hash ${mark(a.dataHashBound)}${subj}`,
1716
- );
1717
- for (const e of a.errors) io.out(paint(` - ${e}`, R));
1718
- }
1719
- }
1720
- }
1721
-
1722
- function reportAgentProof(
1723
- result: AgentProofResult,
1724
- path: string,
1725
- gateways: string[],
1726
- io: Cli,
1727
- sink: JsonSink | null,
1728
- parsed: unknown,
1729
- ): number {
1730
- const marker = result.status === "malformed" ? null : environmentMarker([parsed]);
1731
1456
  if (sink) {
1732
- sink.data = {
1733
- verdict: result.status,
1734
- bundle_status: result.status,
1735
- spec_version: "sanning.agent.proof/v1",
1736
- on_chain_checked: result.onChainChecked,
1737
- notes: [...result.errors],
1738
- ...(marker !== null ? { environment_marker: marker } : {}),
1739
- };
1740
- }
1741
- if (result.status === "malformed") {
1742
- io.err(paint("MALFORMED", R) + ` ${path}`);
1743
- for (const e of result.errors) io.err(` - ${e}`);
1744
- return EXIT_MALFORMED;
1457
+ sink.data = verifyJson(result, grade, declaration, chains, marker, verdict, claims, person);
1745
1458
  }
1459
+ return exit;
1460
+ }
1746
1461
 
1747
- io.out(paint("Agent inclusion proof", B) + ` ${path}`);
1748
- io.out(` spec_version sanning.agent.proof/v1`);
1749
- io.out(` checkpoint ${shortTx(result.checkpointTxId ?? "")} envelope ${mark(result.checkpointOk)}`);
1750
- const onChain = result.onChainOk === null ? "" : ` on-chain ${mark(result.onChainOk)}`;
1751
- io.out(
1752
- ` leaf index ${result.leafIndex} / ${result.leafCount ?? "?"} inclusion ${mark(result.inclusionOk)}${onChain}`,
1753
- );
1754
- io.out("");
1755
- // An agent inclusion proof carries no records and no disclosed bytes, so
1756
- // there is no pack to grade: the bundle status IS the headline here.
1757
- const exit = sandboxExit(exitCodeFor(result.status), marker);
1758
- printRollup(result.status, result.status, null, gateways, result.onChainChecked, io, marker, exit === EXIT_SANDBOX);
1759
- for (const e of result.errors) io.out(paint(` note: ${e}`, DIM));
1462
+ /** One agent reading in `--json`: the same keys in `verify` and `case` and in both kernels. */
1463
+ interface AgentJson {
1464
+ verdict: string | null;
1465
+ reason: string | null;
1466
+ at: string | null;
1467
+ reasons: { reason: string; at: string }[];
1468
+ findings: string[];
1469
+ rendered: string;
1470
+ }
1760
1471
 
1761
- return exit;
1472
+ function agentJson(reading: AgentRecordReading, where: Record<string, unknown> = {}): AgentJson {
1473
+ return {
1474
+ ...where,
1475
+ verdict: reading.verdict,
1476
+ reason: reading.reason ?? null,
1477
+ at: reading.at ?? null,
1478
+ reasons: (reading.reasons ?? []).map((r) => ({ reason: r.reason, at: r.at })),
1479
+ findings: [...reading.findings],
1480
+ rendered: renderAgentReading(reading),
1481
+ };
1762
1482
  }
1763
1483
 
1764
- function printRollup(
1765
- headline: string,
1766
- bundleStatus: string,
1767
- asserted: string | null,
1768
- gateways: string[],
1769
- onChainChecked: boolean,
1770
- io: Cli,
1771
- marker: EnvironmentMarker | null = null,
1772
- sandbox = false,
1773
- simulatedOnly = false,
1774
- ): void {
1775
- // Three scopes, not two. A run with gateways whose only witnesses are
1776
- // simulated looked nothing up (`simulatedOnlyRun`): saying "on-chain" there
1777
- // claims a check that never happened, and saying "no gateway re-fetch
1778
- // requested" denies the flag the reader passed. Every other run that is not
1779
- // counted as checked on a chain, a run that stopped early among them, keeps
1780
- // the second scope as before.
1781
- const scope = onChainChecked
1782
- ? `verified offline + on-chain against ${gateways.length} gateway(s)`
1783
- : simulatedOnly
1784
- ? "verified offline; a simulated witness is not on a chain, so nothing was looked up"
1785
- : "verified offline (no gateway re-fetch requested)";
1786
- let head: string;
1787
- switch (sandbox ? "sandbox" : headline) {
1788
- // Sandbox evidence (exit 5) never prints VERIFIED.
1789
- case "sandbox":
1790
- head = paint(SANDBOX_HEADLINE, Y);
1791
- break;
1792
- case "verified":
1793
- head = paint("VERIFIED", G);
1794
- break;
1795
- // Undetermined, in both of its words. Neither is an accusation, so neither
1796
- // is printed in the color this command uses for one.
1797
- case "partial":
1798
- case "pending":
1799
- case "incomplete":
1800
- head = paint(headline.toUpperCase(), Y);
1801
- break;
1802
- case "failed":
1803
- head = paint("FAILED", R);
1804
- break;
1805
- default:
1806
- head = paint(headline.toUpperCase(), R);
1807
- }
1808
- io.out(`${head} ${paint(scope, DIM)}`);
1809
- if (marker !== null) io.out(paint(` ${SANDBOX_LINE}`, Y));
1810
- // Both verdicts, whenever they differ, because they answer different
1811
- // questions: §3 grades the signed bundle, the pack verdict grades the bundle
1812
- // plus whatever bytes travelled beside it. A pack can be incomplete while the
1813
- // bundle it carries is impeccable, and a reader told only the headline would
1814
- // read a sound bundle as a broken one.
1815
- if (headline !== bundleStatus) {
1816
- io.out(
1817
- paint(` (the signed bundle is "${bundleStatus}"; the pack around it is "${headline}")`, DIM),
1818
- );
1819
- }
1820
- if (asserted && asserted !== bundleStatus) {
1821
- io.out(
1822
- paint(
1823
- ` (the asserting party said "${asserted}"; the recomputed verdict is "${bundleStatus}", ` +
1824
- `and the recomputed verdict wins)`,
1825
- DIM,
1826
- ),
1827
- );
1484
+ const CAUSE_FIELDS = [
1485
+ ["checkpoint_ref", "checkpointRef"],
1486
+ ["signed", "signed"],
1487
+ ["presented", "presented"],
1488
+ ["count", "count"],
1489
+ ["of", "of"],
1490
+ ["first_event_id", "firstEventId"],
1491
+ ["reason", "reason"],
1492
+ ["deviation", "deviation"],
1493
+ ["at", "at"],
1494
+ ["listed_event_id", "listedEventId"],
1495
+ ] as const;
1496
+
1497
+ /**
1498
+ * One pack grade cause in `--json`: its kind, every value it carries, and the sentence
1499
+ * the report prints for it.
1500
+ */
1501
+ function causeJson(cause: PackGradeCause): Record<string, unknown> {
1502
+ const out: Record<string, unknown> = { kind: cause.kind };
1503
+ for (const [name, field] of CAUSE_FIELDS) {
1504
+ const value = cause[field];
1505
+ if (value !== undefined && value !== null) out[name] = value;
1828
1506
  }
1507
+ out.rendered = renderCause(cause, "the pack");
1508
+ return out;
1829
1509
  }
1830
1510
 
1831
1511
  /**
1832
- * The exit code is the PACK verdict's, through the one table (`./exit-codes.ts`).
1833
- *
1834
- * `incomplete` and `partial` are 3: undetermined, which is what 3 means for an
1835
- * unreachable gateway too, and emphatically not 1: the bytes being absent is
1836
- * never reported as tamper (`evidence-bundle.md` §5.1). Until 0.13.0 `partial`
1837
- * exited 0 here unless a gateway was unreachable, while `case` exited 3 on it.
1512
+ * What `verify --json` prints for an evidence bundle: the same keys and words as
1513
+ * Python's `_verify_json`. Every reason and count the person layer prints is here, so a
1514
+ * surface can render `person` from this object alone (`renderPerson`).
1838
1515
  */
1839
- function mapGradeToExit(grade: PackGrade): number {
1840
- return exitCodeFor(grade.verdict);
1516
+ function verifyJson(
1517
+ result: EvidenceBundleResult,
1518
+ grade: PackGrade,
1519
+ declaration: ReturnType<typeof checkDeclaration>,
1520
+ chains: ChainOutcome,
1521
+ marker: EnvironmentMarker | null,
1522
+ verdict: VerdictJson,
1523
+ claims: ClaimEntry[],
1524
+ person: Person,
1525
+ ): Record<string, unknown> {
1526
+ const chainBlock =
1527
+ "unreadable" in chains
1528
+ ? { unreadable: chains.unreadable, chains: 0, findings: [] }
1529
+ : {
1530
+ unreadable: null,
1531
+ chains: chains.chains.length,
1532
+ findings: chains.findings.map((f) => renderChainFinding(f, "this bundle")),
1533
+ };
1534
+ return {
1535
+ // Present only for a pack with an envelope signed `dev` (envelope-spec section 2).
1536
+ ...(marker !== null ? { environment_marker: marker } : {}),
1537
+ verdict,
1538
+ title: VERDICT_TITLES[verdict.status],
1539
+ claims,
1540
+ person,
1541
+ notes: [...person.notes],
1542
+ sandbox: marker !== null,
1543
+ witness: person.witness,
1544
+ witness_state: person.witness_state,
1545
+ bundle_status: result.status,
1546
+ bundle_errors: [...result.errors],
1547
+ body_type: result.bodyType ?? null,
1548
+ on_chain_checked: result.onChainChecked,
1549
+ declaration: declaration.verdict,
1550
+ declaration_notes: declaration.notes.map(renderNote),
1551
+ causes: grade.causes.map(causeJson),
1552
+ records: { presented: result.events.length, content_verified: grade.recordsVerified },
1553
+ chains: chainBlock,
1554
+ events: result.events.map((ev) => ({ event_id: ev.eventId, ok: ev.ok, errors: [...ev.errors] })),
1555
+ agent_records: result.events
1556
+ .filter((ev) => ev.agent !== null)
1557
+ .map((ev) => agentJson(ev.agent!, { event_id: ev.eventId })),
1558
+ };
1841
1559
  }
1842
1560
 
1843
- // ─── What `proof case` reads (proof#141) ─────────────────────────────────────
1844
- //
1845
- // Directories on disk, split into packs. This lives in the CLI module, and not
1846
- // in a kernel module, because it is the command's own read of files a person
1847
- // named on the command line, the same class of read as `verify`'s read of
1848
- // `bundle.json`, and it reads `bundle.json` with `verify`'s own reader,
1849
- // `readBundleBytes`.
1850
- //
1851
- // The case READING is `readCase`, and nothing here touches it. This turns the
1852
- // directories a reader typed into a list of packs. Its twin is the same section
1853
- // of `src/sanning_proof/cli.py`, and both are held to one table,
1854
- // `vectors/case-split.json`, whose rows are directory trees.
1855
- //
1856
- // ── Directories only ──
1857
- //
1858
- // A `.zip` named on the command line is read as the folder `unzip` would make,
1859
- // by the reader in `zip-reader.ts` and nothing else, one reader restated in both
1860
- // kernels because two zip libraries disagree on the edges. A `.zip` INSIDE a
1861
- // folder is a file like any other: outside every pack, hashed like any other
1862
- // file and never unzipped, because a reader does not open a zip inside a zip
1863
- // (§5.6.1).
1561
+ // `proof case` input: directories (or a `.zip` read as the folder `unzip`
1562
+ // makes) split into packs, held to `vectors/case-split.json` in both kernels.
1563
+ // A `.zip` inside a folder is a file like any other (§5.6.1).
1864
1564
  //
1865
- // ── The walk ──
1565
+ // The walk is iterative and bytewise-sorted, never follows a link, opens only
1566
+ // regular files and directories, and refuses the whole input (exit 4) past
1567
+ // MAX_WALK_DEPTH or MAX_WALK_ENTRIES or on a name that is not valid UTF-8.
1866
1568
  //
1867
- // Iterative, never recursive, bytewise-sorted at each level, and bounded:
1569
+ // The split restates the verify page's rule:
1868
1570
  //
1869
- // - A directory nested more than MAX_WALK_DEPTH levels below an argument
1870
- // refuses the whole input (exit 2).
1871
- // - More than MAX_WALK_ENTRIES names listed, across every argument, refuses
1872
- // the whole input (exit 2).
1873
- // - A name that is not valid UTF-8 refuses the whole input (exit 2). It is
1874
- // not escaped or replaced: two different names would then read as one.
1875
- // - A symbolic link is never followed, whatever it points at, and anything
1876
- // else that is not a regular file or a directory is never opened. Each is
1877
- // listed as outside every pack and not read, even when it sits under a
1878
- // pack's `logs/` or is named `bundle.json`.
1879
- //
1880
- // ── The split: the verify page's rule over the tree ──
1881
- //
1882
- // The verify page (`sanning-io/verify-page`, `src/lib/case.ts` `splitCase` and
1883
- // `src/lib/pack.ts` `assemble`) is the other surface that reads a case. Both
1884
- // apply the same splitting rule to the same unpacked tree. The page's rule
1885
- // lives in the page, so neither kernel can call it; it is restated here and in
1886
- // the Python twin, and the shared table pins it:
1887
- //
1888
- // 1. Every regular file whose path ends in `/bundle.json` is a pack, rooted
1889
- // at the directory that holds it.
1890
- // 2. A file belongs to the pack with the LONGEST root it sits under, and only
1891
- // if it is that root's `bundle.json` or under that root's `logs/`.
1892
- // 3. A file under no pack is OUTSIDE: listed, never read as part of a pack, and
1893
- // hashed to be matched against the records (§5.7.6, A41).
1894
- // 4. Roots and outside paths are sorted by UTF-16 code unit (the default
1895
- // `Array.prototype.sort`).
1896
- // 5. Inside a pack, `logs/<event_id>.json` (one level) is disclosed content,
1897
- // and any other file under `logs/` is UNACCOUNTED.
1571
+ // 1. Every regular file at `.../bundle.json` is a pack rooted at its folder.
1572
+ // 2. A file belongs to the pack with the longest root it sits under, only if
1573
+ // it is that root's `bundle.json` or under its `logs/`.
1574
+ // 3. Any other file is outside: listed, and hashed to match against records.
1575
+ // 4. Roots and outside paths sort by UTF-16 code unit.
1576
+ // 5. In a pack, `logs/<event_id>.json` (one level) is disclosed content; any
1577
+ // other file under `logs/` is unaccounted.
1898
1578
  // 6. A pack's `ref` is its root without the trailing slash.
1899
1579
  // 7. No `bundle.json` anywhere refuses.
1900
1580
  //
1901
- // One directory argument is read at `<its name>/<path inside it>`, which is how
1902
- // the page names the files of a dropped folder. Several are read as one folder
1903
- // holding them, each under its own name, and two that would land at one name
1904
- // refuse.
1905
- //
1906
- // Known differences from the page: the page drops a leading byte-order mark from
1907
- // `bundle.json` and `verify`'s reader refuses one; the page reads whatever the
1908
- // browser hands it for a link, a name that is not UTF-8 or a very deep tree,
1909
- // where this command applies the walk's rules above; and the page keys
1910
- // disclosed content by a plain object, so it loses a record named `__proto__`,
1911
- // which this command keeps.
1581
+ // One directory argument reads as `<its name>/<path inside>`; several read as
1582
+ // one folder holding them, and two landing at one name refuse. Unlike the page,
1583
+ // this command refuses a leading BOM in `bundle.json` and keeps a record named
1584
+ // `__proto__`.
1912
1585
 
1913
1586
  /** The error text for an input with no pack in it, word for word the page's. */
1914
1587
  export const NO_BUNDLE =
@@ -1935,7 +1608,7 @@ const UNZIP_FIRST =
1935
1608
 
1936
1609
  /**
1937
1610
  * The input could not be read as a case. The message is the whole report,
1938
- * identical in both kernels, and the command exits 2 on it. `hint` is an
1611
+ * identical in both kernels, and the command exits 4 on it. `hint` is an
1939
1612
  * optional second line naming a way through.
1940
1613
  */
1941
1614
  export class CaseInputError extends Error {
@@ -1955,11 +1628,10 @@ export class CaseInputError extends Error {
1955
1628
  export class WholeReadRefused extends CaseInputError {}
1956
1629
 
1957
1630
  /**
1958
- * `bundle.json` bytes that `readBundleBytes` will not read. `tooDeep` separates
1959
- * the one refusal that is not a parse error; `detail` is the parser's own words,
1960
- * which differ by kernel and are printed by `verify` only. `duplicate` marks the
1961
- * one parse refusal whose `detail` is kernel wording, identical in both kernels
1962
- * (`duplicate key "<name>"`, proof#144), so both commands print it.
1631
+ * `bundle.json` bytes `readBundleBytes` will not read. `tooDeep` marks the one
1632
+ * refusal that is not a parse error. `detail` is the parser's words, which
1633
+ * differ by kernel, so only `verify` prints it; `duplicate` marks the one
1634
+ * `detail` both kernels word identically.
1963
1635
  */
1964
1636
  export class BundleReadError extends Error {
1965
1637
  readonly tooDeep: boolean;
@@ -1973,24 +1645,22 @@ export class BundleReadError extends Error {
1973
1645
  }
1974
1646
  }
1975
1647
 
1976
- // What both commands say about bytes that repeat a member name, before the
1977
- // kernel's own `duplicate key "<name>"`. RFC 8259 JSON permits a repeat and
1978
- // I-JSON (RFC 7493), which JCS is defined over, does not, so "not valid JSON"
1979
- // would be the wrong sentence. `NOT_I_JSON` in the Python twin.
1648
+ // The prefix for a repeated member name. JCS is defined over I-JSON (RFC 7493),
1649
+ // which forbids it, so "not valid JSON" would be the wrong sentence.
1980
1650
  export const NOT_I_JSON = "is not I-JSON (RFC 7493)";
1981
1651
 
1982
- // `ignoreBOM: true` KEEPS a byte-order mark in the text, as Node's
1983
- // `readFile(path, "utf8")` did for `verify`, so a leading one is not JSON.
1652
+ // `ignoreBOM: true` keeps a byte-order mark in the text, so a leading one is
1653
+ // not JSON.
1984
1654
  const bundleDecoder = new TextDecoder("utf-8", { ignoreBOM: true });
1985
1655
  const nameDecoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
1986
1656
 
1987
1657
  /**
1988
- * How both `verify` and `case` turn `bundle.json` bytes into JSON, in both
1989
- * kernels (`read_bundle_bytes` in the Python twin). UTF-8 with each ill-formed
1990
- * sequence read as U+FFFD, and a byte-order mark kept; then nesting deeper than
1991
- * MAX_JSON_DEPTH is refused before any parser walks into it; then `JSON.parse`,
1992
- * refusing an object that repeats a member name (I-JSON, RFC 7493; proof#144).
1993
- * Throws `BundleReadError`.
1658
+ * How `verify` and `case` turn `bundle.json` bytes into JSON, in both kernels:
1659
+ * UTF-8 with ill-formed sequences as U+FFFD and a BOM kept; nesting past
1660
+ * MAX_JSON_DEPTH refused before parsing; then `JSON.parse`, refusing a repeated
1661
+ * member name.
1662
+ *
1663
+ * @throws BundleReadError
1994
1664
  */
1995
1665
  export function readBundleBytes(bytes: Uint8Array): unknown {
1996
1666
  const text = bundleDecoder.decode(bytes);
@@ -2010,9 +1680,8 @@ export interface SplitPack {
2010
1680
  bundlePath: string;
2011
1681
  bundleBytes: Uint8Array;
2012
1682
  /**
2013
- * `logs/<event_id>.json` bytes by event id. A `Map` and not an object: an
2014
- * event id is written by the audited party, and `obj["__proto__"] = bytes`
2015
- * replaces the prototype instead of storing the disclosure.
1683
+ * `logs/<event_id>.json` bytes by event id. A `Map`, because an event id is
1684
+ * written by the audited party and `obj["__proto__"]` would not store it.
2016
1685
  */
2017
1686
  content: Map<string, Uint8Array>;
2018
1687
  unaccounted: string[];
@@ -2020,7 +1689,7 @@ export interface SplitPack {
2020
1689
 
2021
1690
  export interface SplitCase {
2022
1691
  packs: SplitPack[];
2023
- /** Paths that belong to no pack, sorted. Never read as part of a pack; a regular one is hashed, and nothing else is read of it (A41). */
1692
+ /** Paths that belong to no pack, sorted. Never read as part of a pack; a regular one is hashed. */
2024
1693
  outside: string[];
2025
1694
  /** The part of `outside` that is not a regular file. Never followed, never opened. */
2026
1695
  notRegular: string[];
@@ -2037,19 +1706,15 @@ export interface TreeEntry {
2037
1706
  regular: boolean;
2038
1707
  /** The bytes of an entry read out of a case-file `.zip`. */
2039
1708
  data?: Uint8Array | null;
2040
- /**
2041
- * `true` for a symbolic link, on disk or an entry of a `.zip`: the one reason
2042
- * a link is skipped under, here and under `--files`.
2043
- */
1709
+ /** `true` for a symbolic link, on disk or in a `.zip`. */
2044
1710
  link?: boolean;
2045
1711
  /** The `.zip` reader's own words for an entry it refused (not a link). */
2046
1712
  refused?: string;
2047
1713
  }
2048
1714
 
2049
1715
  /**
2050
- * The page's `splitCase` followed by its `assemble` per pack, over the walk's
2051
- * tree. `read` opens a file; it is called for the files a pack owns and for
2052
- * nothing else.
1716
+ * The page's `splitCase` then `assemble` per pack, over the walk's tree. `read`
1717
+ * is called only for the files a pack owns.
2053
1718
  */
2054
1719
  export async function splitCase(
2055
1720
  tree: Map<string, TreeEntry>,
@@ -2114,15 +1779,10 @@ export async function splitCase(
2114
1779
 
2115
1780
  /**
2116
1781
  * The bytes of the `witness-cache.json` beside a pack's `bundle.json`
2117
- * (evidence-bundle 5.6.6, v1.25, A30), or `null`.
2118
- *
2119
- * The split rule is not changed for it: the file stays outside every pack,
2120
- * listed as such, and is read here, for its contents, and hashed with the other
2121
- * outside files (A41). It is read as a cache only where it is a regular file the split left outside every pack, so a file under another
2122
- * pack's `logs/` is that pack's, and a link is never followed. A file that
2123
- * cannot be read is no cache, never a refusal of the drop. One the run's
2124
- * whole-read total has no room for is pushed onto `refused` when it is given,
2125
- * so the output says so.
1782
+ * (evidence-bundle.md §5.6.6), or `null`. Read only where it is a regular file
1783
+ * the split left outside every pack, and it stays outside. An unreadable file
1784
+ * is no cache, never a refusal; one the whole-read total has no room for is
1785
+ * pushed onto `refused` when given.
2126
1786
  */
2127
1787
  export async function witnessCacheBytes(
2128
1788
  tree: Map<string, TreeEntry>,
@@ -2146,33 +1806,30 @@ export async function witnessCacheBytes(
2146
1806
  }
2147
1807
  }
2148
1808
 
2149
- /** `verify`'s reader, with the refusal named by the pack's path. */
2150
- export function loadBundle(pack: SplitPack): unknown {
1809
+ /**
1810
+ * `verify`'s reader over one pack's `bundle.json`: the bundle, or an empty object and why
1811
+ * it could not be read. One pack that cannot be read is that pack's problem, never the
1812
+ * case's: every other pack is still read.
1813
+ */
1814
+ export function loadBundle(pack: SplitPack): [unknown, string | null] {
2151
1815
  try {
2152
- return readBundleBytes(pack.bundleBytes);
1816
+ return [readBundleBytes(pack.bundleBytes), null];
2153
1817
  } catch (e) {
2154
1818
  if (e instanceof BundleReadError && e.tooDeep) {
2155
- throw new CaseInputError(`${pack.bundlePath} nests deeper than ${MAX_JSON_DEPTH} levels`);
1819
+ return [{}, `nests deeper than ${MAX_JSON_DEPTH} levels`];
2156
1820
  }
2157
1821
  if (e instanceof BundleReadError && e.duplicate) {
2158
- throw new CaseInputError(`${pack.bundlePath} ${NOT_I_JSON}: ${e.detail}`);
1822
+ return [{}, `${NOT_I_JSON}: ${e.detail}`];
2159
1823
  }
2160
- throw new CaseInputError(`${pack.bundlePath} is not valid JSON`);
1824
+ return [{}, "is not valid JSON"];
2161
1825
  }
2162
1826
  }
2163
1827
 
2164
- /**
2165
- * The refusal for a file `case` would hold whole that does not fit in what is
2166
- * left of the 1 GiB a run reads whole. The same words in both kernels.
2167
- */
1828
+ /** The refusal for a file `case` would read whole past the run's 1 GiB. Same words in both kernels. */
2168
1829
  export const wholeReadRefusal = (key: string): string =>
2169
1830
  `${key} cannot be read: it would take case past the 1 GiB it reads whole in one run; refusing to read it`;
2170
1831
 
2171
- /**
2172
- * Why `contents.json` reads as an unreadable index when the run's whole-read
2173
- * total has no room for it, and when it cannot be read. The same words in both
2174
- * kernels.
2175
- */
1832
+ /** Why `contents.json` reads as unreadable when it is over budget or cannot be read. */
2176
1833
  export const INDEX_OVER_BUDGET =
2177
1834
  "it would take case past the 1 GiB it reads whole in one run, so it was not read";
2178
1835
  export const INDEX_NOT_READ = "it could not be read";
@@ -2186,15 +1843,13 @@ export const zipFileRefusal = (arg: string): string =>
2186
1843
  `${arg} is larger than 1.25 GiB on disk; refusing to read it`;
2187
1844
 
2188
1845
  /**
2189
- * A regular file's bytes. Opened without following a link and without
2190
- * blocking, then checked again, so a file swapped for a link or a pipe after
2191
- * the walk listed it is refused rather than followed or waited on.
1846
+ * A regular file's bytes, opened without following a link or blocking, then
1847
+ * rechecked, so a swapped-in link or pipe is refused.
2192
1848
  *
2193
- * `budget` is the bytes a run may still hold whole from files on disk
2194
- * (MAX_FOLDER_BYTES at the start). A file larger than what is left, or that
2195
- * grows past it while it is read, is refused before it is held, so a file of a
2196
- * terabyte under a pack's own names cannot exhaust memory. A `.zip` entry is
2197
- * already in memory, inside the archive's own cap, and is not charged.
1849
+ * `budget` is the bytes a run may still hold whole (MAX_FOLDER_BYTES at the
1850
+ * start). A file larger than that, or that grows past it, is refused before it
1851
+ * is held. A `.zip` entry is already in memory under the archive cap and is not
1852
+ * charged.
2198
1853
  */
2199
1854
  export async function readFileEntry(
2200
1855
  entry: TreeEntry,
@@ -2215,8 +1870,8 @@ export async function readFileEntry(
2215
1870
  if (!st.isFile()) throw new CaseInputError(`cannot read ${key}`);
2216
1871
  if (budget === undefined) return new Uint8Array(await handle.readFile());
2217
1872
  if (st.size > budget.n) throw new WholeReadRefused(wholeReadRefusal(key));
2218
- // Once, at what the file says it holds and one byte more to see it grow:
2219
- // the bytes are read into the buffer the result is a view of, never copied.
1873
+ // Ask for one byte more than the size, to see it grow; read in place, never
1874
+ // copied.
2220
1875
  const want = Math.min(st.size, budget.n) + 1;
2221
1876
  const buf = Buffer.allocUnsafeSlow(want);
2222
1877
  let total = 0;
@@ -2226,8 +1881,7 @@ export async function readFileEntry(
2226
1881
  total += bytesRead;
2227
1882
  }
2228
1883
  if (total > st.size) {
2229
- // The file grew since it was measured: take the rest in pieces, never
2230
- // past the budget.
1884
+ // It grew: read the rest in pieces, never past the budget.
2231
1885
  const rest: Buffer[] = [];
2232
1886
  let all = total;
2233
1887
  while (all <= budget.n) {
@@ -2254,9 +1908,8 @@ export async function readFileEntry(
2254
1908
  }
2255
1909
 
2256
1910
  /**
2257
- * Every name under `root`, into `tree` at `<prefix><path inside>`. Breadth
2258
- * first from an explicit queue, never by recursion. `seen` is the count of
2259
- * names listed so far, across every argument.
1911
+ * Every name under `root`, into `tree` at `<prefix><path inside>`, breadth
1912
+ * first, without recursion. `seen` counts names across every argument.
2260
1913
  */
2261
1914
  async function walk(
2262
1915
  root: string,
@@ -2312,8 +1965,7 @@ async function walk(
2312
1965
  }
2313
1966
  }
2314
1967
 
2315
- // The name the page would see for a dropped folder: the last segment of the
2316
- // absolute path, so `.` and `pack/` name the directory itself.
1968
+ // The last segment of the absolute path, so `.` and `pack/` name the directory.
2317
1969
  function baseName(path: string): string {
2318
1970
  return basename(resolve(path));
2319
1971
  }
@@ -2359,8 +2011,7 @@ export async function treeFromArguments(paths: readonly string[]): Promise<Map<s
2359
2011
  }
2360
2012
  let name = baseName(arg);
2361
2013
  if (isZip) {
2362
- // `unzip <name>.zip -d <name>` is the command HOW-TO-VERIFY.txt pins, so
2363
- // the archive is read under the folder that command makes.
2014
+ // Read under the folder `unzip <name>.zip -d <name>` makes.
2364
2015
  name = name.slice(0, -".zip".length);
2365
2016
  if (name.length === 0) throw new CaseInputError(`${arg} has no name before .zip; rename it`);
2366
2017
  }
@@ -2376,11 +2027,9 @@ export async function treeFromArguments(paths: readonly string[]): Promise<Map<s
2376
2027
  }
2377
2028
 
2378
2029
  /**
2379
- * A case file named on the command line, read as the folder `unzip` would make
2380
- * of it: `<name>.zip` is read at `<name>/<entry>` (5.6.4), so the zip and its
2381
- * unpacked folder give one reading. The archive is read by `readZip` and
2382
- * nothing else; an entry it refuses is listed as outside every pack and never
2383
- * read, as the walk lists a link.
2030
+ * A case file named on the command line, read as the folder `unzip` would make:
2031
+ * `<name>.zip` reads at `<name>/<entry>` (§5.6.4). An entry `readZip` refuses
2032
+ * is listed outside every pack and never read.
2384
2033
  */
2385
2034
  async function unzip(
2386
2035
  arg: string,
@@ -2398,8 +2047,7 @@ async function unzip(
2398
2047
  try {
2399
2048
  const size = (await handle.stat()).size;
2400
2049
  if (size > MAX_ZIP_ARCHIVE_BYTES) throw new CaseInputError(zipFileRefusal(arg));
2401
- // One more byte than the size on disk, so a file that grew is noticed; the
2402
- // archive is read straight into this buffer, once, and never copied.
2050
+ // One byte past the size, to notice growth; read in place, never copied.
2403
2051
  const buf = Buffer.allocUnsafe(size + 1);
2404
2052
  let have = 0;
2405
2053
  for (;;) {
@@ -2440,30 +2088,18 @@ async function unzip(
2440
2088
  }
2441
2089
  }
2442
2090
 
2443
- // ─── `proof case` (proof#141) ────────────────────────────────────────────────
2444
- //
2445
- // The case reading from a terminal, so an auditor holding several packs does
2446
- // not depend on Sanning's page for the one check the audited party cannot
2447
- // write for itself. The twin is `run_case` in `src/sanning_proof/cli.py`, and
2448
- // the two commands are compared as child processes by
2449
- // `cross-kernel/run_case_cli.sh`: same stdout, same `--json`, same exit code,
2450
- // over every `case/` vector.
2091
+ // `proof case`: the case reading from a terminal, so an auditor holding several
2092
+ // packs does not depend on Sanning's page. Python's twin is `run_case`;
2093
+ // `cross-kernel/run_case_cli.sh` holds both to the same stdout, `--json` and
2094
+ // exit code. No color, so both print the same bytes.
2451
2095
  //
2452
- // The reading is `readCase`, entire. This command splits the input into packs
2453
- // (the section above, the verify page's rule) and prints what the kernel said.
2454
- // No color: the two commands print the same bytes.
2455
-
2456
- // The exit code of a case verdict is the one table's (`./exit-codes.ts`).
2457
- // `mixed` has its own code, 4, because each other code would say something a
2458
- // mixed case has not established: 0 would soften a case with an unsettled or
2459
- // unreadable outcome in it, 3 would hide the outcomes that did settle, and 1
2460
- // would accuse a case in which nothing was proven wrong. A case holding a
2461
- // failure never exits 4: a proven failure keeps the headline (evidence-bundle
2462
- // 5.5.4 v1.16, A17), so it reads `failed` and exits 1.
2096
+ // `mixed` exits 4 because every other code would claim something unproven: 0
2097
+ // softens an unsettled outcome, 3 hides the settled ones, 1 accuses. A proven
2098
+ // failure keeps the headline (evidence-bundle.md §5.5.4) and exits 1.
2463
2099
 
2464
2100
  export const CASE_USAGE = `sanning-proof case -- a case reading: one verdict over one pack or many, offline
2465
2101
 
2466
- sanning-proof case <folder or case file>... [--gateways <url,url>] [--files <path>...] [--enclave-policy <file>] [--enclave-rpc <url>] [--json]
2102
+ sanning-proof case <folder or case file>... [--gateways <url,url>] [--files <path>...] [--json]
2467
2103
 
2468
2104
  <folder> a pack folder (bundle.json plus logs/), a folder that holds
2469
2105
  several packs, or an unzipped case file. Packs are found by the
@@ -2476,7 +2112,7 @@ export const CASE_USAGE = `sanning-proof case -- a case reading: one verdict ove
2476
2112
  read, even under a pack's logs/. Several arguments are read as
2477
2113
  one folder holding them, each under its own name. A folder
2478
2114
  nested more than 32 levels deep, more than 50000 files and
2479
- folders in all, or a name that is not valid UTF-8 exits 2.
2115
+ folders in all, or a name that is not valid UTF-8 exits 4.
2480
2116
  <case file> a case file named as <name>.zip, read as the folder
2481
2117
  unzip <name>.zip -d <name> would make, so the two give one
2482
2118
  reading. An entry that is encrypted, a symbolic link, stored by
@@ -2487,7 +2123,9 @@ export const CASE_USAGE = `sanning-proof case -- a case reading: one verdict ove
2487
2123
  is listed; the index never moves the verdict.
2488
2124
  --gateways comma-separated gateway URLs. Each pack's witnesses are then
2489
2125
  looked up on the public record, pack by pack, and reported on
2490
- the witness line. The lookup never changes the verdict word.
2126
+ the witness line. Only a lookup that finds other bytes, or
2127
+ another witness time, changes the verdict: it disproves
2128
+ witnessed, and the case reads failed.
2491
2129
  --files files handed in beside the packs, every path up to the next
2492
2130
  option, matched by SHA-256 alone against what every pack's
2493
2131
  signed records commit to. A folder stands for the regular files
@@ -2495,23 +2133,13 @@ export const CASE_USAGE = `sanning-proof case -- a case reading: one verdict ove
2495
2133
  inside it is listed as skipped and never followed. A file under
2496
2134
  a pack's logs/ is offered only when it is named here. The
2497
2135
  verdict never moves.
2498
- --json the full reading as JSON: the verdict, every exception and note
2136
+ --json the full reading as JSON: the verdict, the six claims, the
2137
+ person layer, every exception and note
2499
2138
  with its rendered line, every record of every pack with its
2500
2139
  status (read clean, read flagged, or not read and why), every
2501
2140
  contribution, the packs, each with its index, the files outside
2502
2141
  them, the case file index comparison, the witness line and the
2503
2142
  on-chain half.
2504
- --enclave-policy
2505
- a sanning.enclave-policy/v1 file: what this reader accepts of
2506
- an enclave attestation. Where the packs hold a runner's run, an
2507
- enclave attestation line and a run-account line print beside
2508
- the witness line (DRAFT); neither moves the verdict or the exit
2509
- code. Without a policy, or without an adapter for the provider
2510
- installed, the line reads not_checked.
2511
- --enclave-rpc
2512
- a chain JSON-RPC endpoint: a flare-fcc run also gets the online
2513
- line, read from the contracts the policy names. It moves
2514
- nothing either.
2515
2143
 
2516
2144
  Files outside every pack are matched too, always, so
2517
2145
  sanning-proof case audit-file.zip checks a whole download in one command: each
@@ -2529,7 +2157,7 @@ kernel and graded as a pack; every record every pack supplied is read, in one
2529
2157
  pile, under the hand-off rules of envelope-spec section 4.1; a hand-off record
2530
2158
  with no counterpart among the records read is always named, and when a pack
2531
2159
  signed by the key it names is in the reading, it says that pack holds none,
2532
- unless every candidate it had was accounted for by another record (A20),
2160
+ unless every candidate it had was accounted for by another record,
2533
2161
  which reads as no counterpart and names no pack.
2534
2162
 
2535
2163
  What it does not establish: that the set of packs is complete (a party can
@@ -2540,19 +2168,18 @@ that any witness is on the public record, which the reading never looks up.
2540
2168
  ${EXIT_CODES_HELP}
2541
2169
 
2542
2170
  case: an input that cannot be read as packs, or an unexpected error in this
2543
- command, exits 2. With --gateways, a witness that does not match on chain exits
2544
- 1, and an on-chain half that could not be settled turns 0 into 3.`;
2171
+ command, exits 4. With --gateways, a witness that does not match on chain
2172
+ exits 1; a lookup that could not be settled moves nothing, and the witness line
2173
+ says so.`;
2545
2174
 
2546
- // What Node writes for a lone surrogate on a UTF-8 stream is U+FFFD. Done here
2547
- // rather than left to the stream, so the lines a test captures in process are
2548
- // the lines a reader sees.
2175
+ // Node writes U+FFFD for a lone surrogate; doing it here makes captured lines
2176
+ // match what a reader sees.
2549
2177
  function noLoneSurrogates(text: string): string {
2550
2178
  return text.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "�");
2551
2179
  }
2552
2180
 
2553
- // Keys sorted at every depth, arrays left in their order: the Python twin
2554
- // writes `json.dumps(sort_keys=True, indent=2, ensure_ascii=False)` and this is
2555
- // that, byte for byte.
2181
+ // Keys sorted at every depth, arrays in order: byte for byte Python's
2182
+ // `json.dumps(sort_keys=True, indent=2, ensure_ascii=False)`.
2556
2183
  function sortKeys(v: unknown): unknown {
2557
2184
  if (Array.isArray(v)) return v.map(sortKeys);
2558
2185
  if (v !== null && typeof v === "object") {
@@ -2570,32 +2197,19 @@ function jsonText(v: unknown): string {
2570
2197
  }
2571
2198
 
2572
2199
 
2573
- /**
2574
- * The case verdict's code, moved only by an on-chain half that was asked for:
2575
- * a witness that does not match is a failure whatever the reading said, and an
2576
- * on-chain half that could not be settled is never a clean 0.
2577
- */
2578
- export function caseExitCode(verdict: string, onChainVerdict: string): number {
2579
- const code = exitCodeFor(verdict);
2580
- if (onChainVerdict === "failed") return EXIT_FAILED;
2581
- if (onChainVerdict === "undetermined" && code === EXIT_VERIFIED) return EXIT_UNDETERMINED;
2582
- return code;
2583
- }
2584
-
2585
2200
  export interface CaseFileData {
2586
2201
  root: string;
2587
2202
  index: string;
2588
2203
  case_id: string | null;
2589
- findings: { rendered: string }[];
2204
+ findings: { kind: string; rendered: string }[];
2590
2205
  }
2591
2206
 
2592
2207
  interface CaseData {
2593
2208
  title: string;
2209
+ person: Person;
2594
2210
  reliance: string;
2595
2211
  summary: string;
2596
2212
  witness: { rendered: string };
2597
- enclave_attestation?: { rendered: string[] }[];
2598
- run_account?: { rendered: string }[];
2599
2213
  exceptions: { rendered: string }[];
2600
2214
  notes: { rendered: string }[];
2601
2215
  packs: {
@@ -2607,7 +2221,13 @@ interface CaseData {
2607
2221
  grade: string | null;
2608
2222
  environment_marker?: EnvironmentMarker;
2609
2223
  }[];
2610
- records: { pack_index: number; status: string }[];
2224
+ records: {
2225
+ pack_index: number;
2226
+ status: string;
2227
+ pack_ref: string;
2228
+ event_id: string;
2229
+ agent: AgentJson | null;
2230
+ }[];
2611
2231
  environment_marker?: EnvironmentMarker;
2612
2232
  outside: string[];
2613
2233
  not_regular: string[];
@@ -2622,26 +2242,16 @@ const titleOf = (word: string): string =>
2622
2242
  : word;
2623
2243
 
2624
2244
  /**
2625
- * The text report, one line per entry, identical in both kernels. The headline
2626
- * is the verdict's one display title and nothing else sets it (5.5.6, D7); the
2627
- * scope sentence and the witness line are printed with it every time.
2245
+ * The text report, one line per list entry, identical in both kernels. The person
2246
+ * layer comes first (the title, one line per claim that is not proven, the next action,
2247
+ * the notes, the sandbox line and the witness line), then the scope sentence (section
2248
+ * 5.5.6), what a reader may rely on, and the detail.
2628
2249
  */
2629
2250
  function caseReport(data: CaseData): string[] {
2630
- const lines = [data.title];
2631
- // Sandbox evidence: printed with every headline the marker is on.
2632
- if (data.environment_marker !== undefined) lines.push(` ${SANDBOX_LINE}`);
2633
- lines.push(` ${data.summary}`, ` ${data.witness.rendered}`);
2634
- // Where the packs hold a runner's run: its enclave attestation line and its
2635
- // run-account line, beside the witness line.
2636
- const enclave = data.enclave_attestation ?? [];
2637
- const account = data.run_account ?? [];
2638
- for (let i = 0; i < Math.min(enclave.length, account.length); i++) {
2639
- for (const line of enclave[i]!.rendered) lines.push(` ${line}`);
2640
- lines.push(` ${account[i]!.rendered}`);
2641
- }
2642
- lines.push(` ${data.reliance}`);
2251
+ const lines = personText(data.person);
2252
+ lines.push("");
2253
+ lines.push(data.summary, data.reliance);
2643
2254
  for (const e of data.exceptions) lines.push(` - ${e.rendered}`);
2644
- for (const n of data.notes) lines.push(` note: ${n.rendered}`);
2645
2255
  lines.push(`packs ${data.packs.length}`);
2646
2256
  for (const p of data.packs) {
2647
2257
  const key = p.signing_key !== null ? p.signing_key : "a key this reader cannot read";
@@ -2649,9 +2259,9 @@ function caseReport(data: CaseData): string[] {
2649
2259
  p.grade === null
2650
2260
  ? "not graded"
2651
2261
  : p.grade === "verified" && p.environment_marker !== undefined
2652
- ? SANDBOX_TITLE
2262
+ ? "Verified, sandbox"
2653
2263
  : titleOf(p.grade);
2654
- // Each record's status, counted per pack (A43): the JSON lists every one.
2264
+ // Counted per pack; the JSON lists every record.
2655
2265
  const mine = data.records.filter((r) => r.pack_index === p.index);
2656
2266
  const count = (status: string) => mine.filter((r) => r.status === status).length;
2657
2267
  const records =
@@ -2667,6 +2277,17 @@ function caseReport(data: CaseData): string[] {
2667
2277
  }
2668
2278
  lines.push(line);
2669
2279
  }
2280
+ // Every agent record that did not read verified, or carries a finding. A
2281
+ // finding is reported beside its record and moves no verdict.
2282
+ const agents = data.records.filter((r) => r.agent !== null);
2283
+ if (agents.length > 0) {
2284
+ const shown = agents.filter((r) => r.agent!.verdict !== "verified" || r.agent!.findings.length > 0);
2285
+ lines.push(
2286
+ `agent records ${agents.length} read across every pack, ` +
2287
+ `${agents.length - shown.length} verified with no finding`,
2288
+ );
2289
+ for (const r of shown) lines.push(` ${r.pack_ref}/${r.event_id} ${r.agent!.rendered}`);
2290
+ }
2670
2291
  if (data.outside.length > 0) {
2671
2292
  lines.push(
2672
2293
  `outside ${data.outside.length} file(s) sit outside every ` +
@@ -2677,9 +2298,6 @@ function caseReport(data: CaseData): string[] {
2677
2298
  lines.push(links.has(path) ? ` - ${path} (not a regular file: not followed, not read)` : ` - ${path}`);
2678
2299
  }
2679
2300
  }
2680
- for (const path of data.witness_cache_not_read ?? []) {
2681
- lines.push(`note ${witnessCacheRefusedNote(path)}`);
2682
- }
2683
2301
  for (const c of data.case_file) {
2684
2302
  if (c.index === "absent") continue;
2685
2303
  const label = c.case_id !== null ? `, which says it is about ${c.case_id}` : "";
@@ -2692,8 +2310,7 @@ function caseReport(data: CaseData): string[] {
2692
2310
  }
2693
2311
  const oc = data.on_chain;
2694
2312
  if (oc.checked) {
2695
- // A drop whose only witnesses are simulated had nothing looked up on a
2696
- // chain, and the header says so rather than "looked up against".
2313
+ // Only simulated witnesses: nothing was looked up on a chain.
2697
2314
  const simulated = oc.packs.reduce((n, r) => n + (r.simulated ?? 0), 0);
2698
2315
  const onlySimulated =
2699
2316
  simulated > 0 &&
@@ -2720,12 +2337,10 @@ function caseReport(data: CaseData): string[] {
2720
2337
  }
2721
2338
 
2722
2339
  /**
2723
- * The index comparison, once per argument (5.6.3): each folder or case file
2724
- * whose root holds `contents.json`. Every file under `packs/` and `report/` is
2725
- * hashed for it, in pieces, as bytes, and nothing is parsed but the index. The
2726
- * files share one total of 1 GiB across the run, charged in path order; a file
2727
- * larger than what is left is reported as too large to hash, never as a match
2728
- * and never as absent. It is never an input to the reading.
2340
+ * The index comparison, once per argument with a `contents.json` at its root
2341
+ * (§5.6.3). Files under `packs/` and `report/` are hashed in pieces, sharing a
2342
+ * 1 GiB total in path order; a file past what is left reports as too large,
2343
+ * never as a match or absent. Never an input to the reading.
2729
2344
  */
2730
2345
  export async function caseFileReadings(
2731
2346
  tree: Map<string, TreeEntry>,
@@ -2758,9 +2373,8 @@ export async function caseFileReadings(
2758
2373
  .filter((key) => key.startsWith(prefix) && tree.get(key)!.regular)
2759
2374
  .map((key) => [key, key.slice(prefix.length)] as const)
2760
2375
  .filter(([, rel]) => rel.startsWith("packs/") || rel.startsWith("report/"));
2761
- // The files the index lists are charged first, then the rest, each group
2762
- // in UTF-16 order, so a file it does not list cannot use up the total and
2763
- // turn a real mismatch into `too_large`.
2376
+ // Listed files are charged first, so an unlisted file cannot use up the
2377
+ // total and turn a real mismatch into `too_large`.
2764
2378
  for (const [key, rel] of [
2765
2379
  ...candidates.filter(([, rel]) => listed.has(rel)),
2766
2380
  ...candidates.filter(([, rel]) => !listed.has(rel)),
@@ -2803,25 +2417,23 @@ export async function caseFileReadings(
2803
2417
  return out as CaseFileData[];
2804
2418
  }
2805
2419
 
2806
- // One pack's environment marker, as the key its JSON row carries only where
2807
- // the pack holds an envelope signed `dev`.
2420
+ // The pack's environment marker, present only for a pack with a `dev` envelope.
2808
2421
  function packMarker(bundle: unknown): { environment_marker?: EnvironmentMarker } {
2809
2422
  const m = environmentMarker([bundle]);
2810
2423
  return m !== null ? { environment_marker: m } : {};
2811
2424
  }
2812
2425
 
2813
2426
  function caseError(e: CaseInputError, asJson: boolean, io: Cli): number {
2814
- if (asJson) outJson(io, jsonText({ error: e.message, exit_code: EXIT_MALFORMED, hint: e.hint }));
2427
+ if (asJson) outJson(io, jsonText({ error: e.message, exit_code: EXIT_COULD_NOT_RUN, hint: e.hint }));
2815
2428
  io.err(noLoneSurrogates(`case: ${e.message}`));
2816
2429
  if (e.hint) io.err(noLoneSurrogates(`case: ${e.hint}`));
2817
- return EXIT_MALFORMED;
2430
+ return EXIT_COULD_NOT_RUN;
2818
2431
  }
2819
2432
 
2820
2433
  /**
2821
- * `proof case <folder>... [--gateways <urls>] [--json]`. Every path out of the
2822
- * command is an exit code from the table: an exception nobody expected exits 2,
2823
- * never 1, because 1 would name the evidence failed over a defect in this
2824
- * command.
2434
+ * `proof case <folder>... [--gateways <urls>] [--json]`. An unexpected
2435
+ * exception exits 4, never 1: 1 would call the evidence failed over a defect in
2436
+ * this command.
2825
2437
  */
2826
2438
  export async function runCase(argv: readonly string[], stream: Cli): Promise<number> {
2827
2439
  const io = guard(stream);
@@ -2839,26 +2451,16 @@ export async function runCase(argv: readonly string[], stream: Cli): Promise<num
2839
2451
  io,
2840
2452
  );
2841
2453
  } catch {
2842
- return EXIT_MALFORMED; // a stream that cannot be written
2454
+ return EXIT_COULD_NOT_RUN; // the stream cannot be written
2843
2455
  }
2844
2456
  }
2845
2457
  }
2846
2458
 
2847
- // The files that sit outside every pack, as files offered (A41, proof#215).
2848
- //
2849
- // Every regular file the split left outside, in the split's order, hashed
2850
- // exactly as a `--files` file is (a folder's file in pieces, a `.zip` entry
2851
- // from the bytes the reader already holds) and named by its path in the tree,
2852
- // `<case name>/<path inside>`, so a `.zip` and the folder `unzip` makes of it
2853
- // name each file alike. A link, a pipe, an entry the zip reader refused (with
2854
- // its own words) and a file that cannot be read are returned as skipped, named
2855
- // and never opened as a pack file. A pack's own files, `bundle.json` and what
2856
- // is under `logs/`, are not outside and are not offered.
2857
- //
2858
- // The files share one total of 1 GiB, the cap the zip reader puts on an
2859
- // archive, charged in that order. A file larger than what is left is skipped as
2860
- // `too_large` and read no further, so a sparse file of a terabyte in a
2861
- // downloaded folder cannot keep `case` from reaching its verdict.
2459
+ // The files outside every pack, as files offered (§5.7), in split order. Each
2460
+ // is hashed as a `--files` file is and named `<case name>/<path inside>`, so a
2461
+ // `.zip` and its unzipped folder name files alike. Links, pipes, refused zip
2462
+ // entries and unreadable files are returned as skipped. They share the 1 GiB
2463
+ // total; a file past it is `too_large` and read no further.
2862
2464
  export async function looseFiles(
2863
2465
  tree: Map<string, TreeEntry>,
2864
2466
  split: SplitCase,
@@ -2898,67 +2500,11 @@ export async function looseFiles(
2898
2500
  return { offered, skipped };
2899
2501
  }
2900
2502
 
2901
- // A policy file that is not JSON: read, and unreadable (`policy_unreadable`).
2902
- const POLICY_NOT_JSON = Symbol("policy not JSON");
2903
-
2904
- async function readEnclavePolicy(path: string | null): Promise<unknown> {
2905
- if (path === null) return null;
2906
- let raw: Uint8Array;
2907
- try {
2908
- if (!(await stat(path)).isFile()) throw new CaseInputError(`--enclave-policy ${path} is not a readable file`);
2909
- raw = new Uint8Array(await readFile(path));
2910
- } catch (e) {
2911
- if (e instanceof CaseInputError) throw e;
2912
- throw new CaseInputError(`--enclave-policy ${path} is not a readable file`);
2913
- }
2914
- try {
2915
- return parseRefusingDuplicates(new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(raw));
2916
- } catch {
2917
- return POLICY_NOT_JSON;
2918
- }
2919
- }
2920
-
2921
- /** The two line members of `--json`: one entry per run, with the lines it prints. */
2922
- function runLines(runs: readonly RunReading[]): [Record<string, unknown>[], Record<string, unknown>[]] {
2923
- const enclave = runs.map((r) => ({
2924
- pack_ref: r.packRef,
2925
- pack_index: r.packIndex,
2926
- event_id: r.eventId,
2927
- runner_public_key: r.runnerPublicKey,
2928
- agent_public_key: r.agentPublicKey,
2929
- state: r.enclave.state,
2930
- kinds: r.enclave.kinds,
2931
- notes: r.enclave.notes,
2932
- reason: r.enclave.reason,
2933
- provider: r.enclave.provider,
2934
- evaluation: r.enclave.evaluation,
2935
- evaluated_at: r.enclave.evaluatedAt,
2936
- token_checked: r.enclave.tokenChecked,
2937
- online: r.online !== null ? { state: r.online.state, kinds: r.online.kinds } : null,
2938
- rendered: renderEnclaveLines(r).map(escapeControl),
2939
- }));
2940
- const account = runs.map((r) => ({
2941
- pack_ref: r.packRef,
2942
- pack_index: r.packIndex,
2943
- event_id: r.eventId,
2944
- runner_public_key: r.runnerPublicKey,
2945
- state: r.account.state,
2946
- findings: r.account.findings.map((f) => ({ kind: f.kind, event_id: f.eventId })),
2947
- notes: r.account.notes.map((n) => ({ kind: n.kind, event_id: n.eventId })),
2948
- closing_event_id: r.account.closingEventId,
2949
- reason: r.account.reason,
2950
- rendered: escapeControl(renderAccountLine(r)),
2951
- }));
2952
- return [enclave, account];
2953
- }
2954
-
2955
2503
  async function caseCommand(argv: readonly string[], io: Cli): Promise<number> {
2956
2504
  let asJson = false;
2957
2505
  let gateways: string[] = [];
2958
2506
  const paths: string[] = [];
2959
2507
  let filePaths: string[] | undefined;
2960
- let enclavePolicy: string | null = null;
2961
- let enclaveRpc: string | null = null;
2962
2508
  for (let i = 0; i < argv.length; i++) {
2963
2509
  const arg = argv[i]!;
2964
2510
  if ((arg === "--help" || arg === "-h" || arg === "help") && paths.length === 0) {
@@ -2972,7 +2518,7 @@ async function caseCommand(argv: readonly string[], io: Cli): Promise<number> {
2972
2518
  if (next === undefined) {
2973
2519
  io.err("case: --gateways requires a comma-separated list of URLs");
2974
2520
  outBlock(io, CASE_USAGE);
2975
- return EXIT_MALFORMED;
2521
+ return EXIT_COULD_NOT_RUN;
2976
2522
  }
2977
2523
  gateways = next
2978
2524
  .split(",")
@@ -2984,24 +2530,14 @@ async function caseCommand(argv: readonly string[], io: Cli): Promise<number> {
2984
2530
  if (taken.length === 0) {
2985
2531
  io.err("case: --files requires at least one file path");
2986
2532
  outBlock(io, CASE_USAGE);
2987
- return EXIT_MALFORMED;
2533
+ return EXIT_COULD_NOT_RUN;
2988
2534
  }
2989
2535
  filePaths = [...(filePaths ?? []), ...taken];
2990
2536
  i += taken.length;
2991
- } else if (arg === "--enclave-policy" || arg === "--enclave-rpc") {
2992
- const next = argv[i + 1];
2993
- if (next === undefined) {
2994
- io.err(arg === "--enclave-policy" ? "case: --enclave-policy requires a policy file" : "case: --enclave-rpc requires a URL");
2995
- outBlock(io, CASE_USAGE);
2996
- return EXIT_MALFORMED;
2997
- }
2998
- if (arg === "--enclave-policy") enclavePolicy = next;
2999
- else enclaveRpc = next;
3000
- i++;
3001
2537
  } else if (arg.startsWith("--")) {
3002
2538
  io.err(`case: unknown option ${arg}`);
3003
2539
  outBlock(io, CASE_USAGE);
3004
- return EXIT_MALFORMED;
2540
+ return EXIT_COULD_NOT_RUN;
3005
2541
  } else {
3006
2542
  paths.push(arg);
3007
2543
  }
@@ -3009,7 +2545,7 @@ async function caseCommand(argv: readonly string[], io: Cli): Promise<number> {
3009
2545
  if (paths.length === 0) {
3010
2546
  io.err("case: at least one pack is required");
3011
2547
  outBlock(io, CASE_USAGE);
3012
- return EXIT_MALFORMED;
2548
+ return EXIT_COULD_NOT_RUN;
3013
2549
  }
3014
2550
 
3015
2551
  let offered: OfferedFile[] | undefined;
@@ -3020,29 +2556,23 @@ async function caseCommand(argv: readonly string[], io: Cli): Promise<number> {
3020
2556
  offered = read.offered;
3021
2557
  skipped = read.skipped;
3022
2558
  }
3023
- let policy: unknown;
3024
- try {
3025
- policy = await readEnclavePolicy(enclavePolicy);
3026
- } catch (e) {
3027
- if (e instanceof CaseInputError) return caseError(e, asJson, io);
3028
- throw e;
3029
- }
3030
2559
 
3031
2560
  const whole = { n: MAX_FOLDER_BYTES };
3032
2561
  let split;
3033
2562
  let tree: Map<string, TreeEntry>;
3034
- let bundles: [SplitPack, unknown][];
2563
+ let loaded: [SplitPack, [unknown, string | null]][];
3035
2564
  try {
3036
2565
  tree = await treeFromArguments(paths);
3037
2566
  split = await splitCase(tree, (entry, key) => readFileEntry(entry, key, whole));
3038
- bundles = split.packs.map((p): [SplitPack, unknown] => [p, loadBundle(p)]);
2567
+ loaded = split.packs.map((p): [SplitPack, [unknown, string | null]] => [p, loadBundle(p)]);
3039
2568
  } catch (e) {
3040
2569
  if (e instanceof CaseInputError) return caseError(e, asJson, io);
3041
2570
  throw e;
3042
2571
  }
2572
+ const bundles = loaded.map(([p, [bundle]]): [SplitPack, unknown] => [p, bundle]);
3043
2573
 
3044
2574
  const reading = await readCase(
3045
- bundles.map(([p, bundle]) => ({ ref: p.ref, bundle, content: p.content })),
2575
+ loaded.map(([p, [bundle, why]]) => ({ ref: p.ref, bundle, content: p.content, unreadable: why })),
3046
2576
  );
3047
2577
  let caseFile: CaseFileData[];
3048
2578
  try {
@@ -3056,34 +2586,68 @@ async function caseCommand(argv: readonly string[], io: Cli): Promise<number> {
3056
2586
  for (const [p, bundle] of bundles) {
3057
2587
  caches.push(readWitnessCache(bundle, await witnessCacheBytes(tree, split, p, whole, cacheRefused)));
3058
2588
  }
3059
- const confirmedStamps: string[][] = [];
3060
2589
  const onChain = await onChainHalf(
3061
2590
  bundles.map(([p, bundle], i): [string, unknown, CachedWitnessTime[]] => [p.ref, bundle, caches[i]!]),
3062
2591
  gateways,
3063
2592
  { ...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}) },
3064
- confirmedStamps,
3065
2593
  );
3066
2594
  const witness = witnessLine(onChain, caches.flat());
3067
- // Sandbox evidence: over every envelope every pack holds, and per
3068
- // pack for its row. Only a case that would otherwise exit 0 exits 5.
2595
+ // Sandbox evidence, over every pack and per pack for its row. A label, never a
2596
+ // verdict: a case that verifies over a `dev` record exits 3.
3069
2597
  const marker = environmentMarker(bundles.map(([, b]) => b));
3070
- // Undetermined only because a simulated witness cannot be looked up: every
3071
- // other witness confirmed, none unreachable or mismatched, every pack names
3072
- // one. With every record signed `dev` that is sandbox evidence, exit 5.
3073
- const simulatedOnly =
3074
- reading.verdict === "verified" &&
3075
- onChain.verdict === "undetermined" &&
3076
- onChain.packs.some((r) => (r.simulated ?? 0) > 0) &&
3077
- onChain.packs.every(
3078
- (r) => r.witnesses > 0 && r.unreachable === 0 && r.mismatched === 0 && r.not_checked === (r.simulated ?? 0),
3079
- );
3080
- const code = sandboxExit(caseExitCode(reading.verdict, onChain.verdict), marker, simulatedOnly);
2598
+ // The lookup is outside the reading (it makes network calls the reading never does).
2599
+ // Only a lookup that finds other bytes, or another time, disproves `witnessed`.
2600
+ const problems: Problem[] = [...reading.problems];
2601
+ onChain.packs.forEach((row, index) => {
2602
+ if (row.mismatched > 0) problems.push(problem("witness_differs", [[index, null]]));
2603
+ if ((row.witness_times ?? []).some((t) => t.outcome === "differs")) {
2604
+ problems.push(problem("witness_time_differs", [[index, null]]));
2605
+ }
2606
+ });
2607
+ const inScope: string[] = [];
2608
+ const notInScope: Partial<Record<Claim, NotInScopeReason>> = {};
2609
+ if (reading.handoffRecords > 0 || reading.packs.some((p) => p.declarationVersion !== null)) {
2610
+ inScope.push("complete");
2611
+ } else {
2612
+ notInScope.complete = "no_declaration_and_no_handoff";
2613
+ }
2614
+ if (reading.handoffRecords > 0) inScope.push("agreed");
2615
+ else notInScope.agreed = "no_handoff";
2616
+ if (reading.handoffRecords > 0 || reading.records.some((r) => r.agent !== null)) {
2617
+ inScope.push("follows_rules");
2618
+ } else {
2619
+ notInScope.follows_rules = "no_agent_or_handoff_records";
2620
+ }
2621
+ const packRefs = reading.packs.map((p) => p.ref);
2622
+ const recordIds = new Map(reading.records.map((r) => [`${r.packIndex}/${r.recordIndex}`, r.eventId]));
2623
+ const claims = claimsOf(problems, inScope, {
2624
+ notInScope,
2625
+ labels: (pack, record) => [
2626
+ pack !== null ? (packRefs[pack] ?? null) : null,
2627
+ recordIds.get(`${pack}/${record}`) ?? null,
2628
+ ],
2629
+ });
2630
+ const verdict = verdictOf(claims);
2631
+ const code = exitCodeFor(verdict.status, marker);
2632
+ const notes = reading.notes.map((n) => note(n.kind, renderCaseNote(n)));
2633
+ notes.push(...findingNotes(reading.records.map((r) => r.agent)));
2634
+ notes.push(...caseFile.flatMap((c) => c.findings.map((f) => note(f.kind, f.rendered))));
2635
+ notes.push(...cacheRefused.map((p) => note("witness_cache_refused", witnessCacheRefusedNote(p))));
2636
+ const person = renderPerson(verdict, claims, orderedNotes(notes), marker, witnessText(witness), witness.state);
3081
2637
  const nil = <T>(v: T | undefined): T | null => (v === undefined ? null : v);
3082
2638
 
3083
2639
  const data = {
3084
- verdict: reading.verdict,
3085
- title: reading.verdict === "verified" && marker !== null ? SANDBOX_TITLE : titleOf(reading.verdict),
3086
- reliance: caseReliance(reading.verdict, reading.handoffRecords),
2640
+ verdict,
2641
+ title: titleOf(verdict.status),
2642
+ reliance: caseReliance(
2643
+ verdict.status,
2644
+ reading.handoffRecords,
2645
+ marker,
2646
+ verdict.reason_kind ?? null,
2647
+ ),
2648
+ claims,
2649
+ person,
2650
+ sandbox: marker !== null,
3087
2651
  exit_code: code,
3088
2652
  summary: renderCaseSummary(reading),
3089
2653
  witness: {
@@ -3130,6 +2694,7 @@ async function caseCommand(argv: readonly string[], io: Cli): Promise<number> {
3130
2694
  findings: r.findings,
3131
2695
  failed_checks: r.failedChecks,
3132
2696
  counterparts: r.counterparts,
2697
+ agent: r.agent === null ? null : agentJson(r.agent),
3133
2698
  })),
3134
2699
  contributions: reading.contributions.map(([who, verdict]) => [who, verdict]),
3135
2700
  packs: bundles.map(([p], i) => ({
@@ -3151,24 +2716,9 @@ async function caseCommand(argv: readonly string[], io: Cli): Promise<number> {
3151
2716
  ...(cacheRefused.length > 0 ? { witness_cache_not_read: cacheRefused } : {}),
3152
2717
  ...(marker !== null ? { environment_marker: marker } : {}),
3153
2718
  };
3154
- // The runs in the drop (DRAFT, specs/enclave-attestation.md and
3155
- // run-records.md): an enclave attestation line and a run-account line per
3156
- // run, beside the witness line. Neither moves `code`, and a drop that holds
3157
- // no run prints and writes exactly what it did before.
3158
- const runs = (
3159
- await readRuns(
3160
- bundles.map(([p, bundle]) => ({ ref: p.ref, bundle, content: p.content })),
3161
- { policy, adapters: await loadInstalledAdapters(), rpc: enclaveRpc, witnessed: confirmedStamps },
3162
- )
3163
- ).runs;
3164
- const withRuns: Record<string, unknown> = data;
3165
- if (runs.length > 0) [withRuns.enclave_attestation, withRuns.run_account] = runLines(runs);
3166
- // The files outside every pack (evidence-bundle.md 5.7, A29, A41), always,
3167
- // then the files after --files. A pack's logs/ is its side content and is
3168
- // never an offered file; only the paths after --files are. Both are matched
3169
- // by SHA-256 alone and nothing here moves `code`. The section is printed when
3170
- // there is a file to say something about, so a case with nothing outside
3171
- // every pack and no --files prints what it always did.
2719
+ // Files outside every pack, then the `--files` paths (evidence-bundle.md
2720
+ // §5.7). A pack's `logs/` is never offered. Matched by SHA-256 alone; nothing
2721
+ // here moves `code`. Printed only when there is something to say.
3172
2722
  const loose = await looseFiles(tree, split);
3173
2723
  let fileLines: string[] = [];
3174
2724
  const out: Record<string, unknown> = data;
@@ -3181,9 +2731,8 @@ async function caseCommand(argv: readonly string[], io: Cli): Promise<number> {
3181
2731
  skipped: [...loose.skipped, ...skipped],
3182
2732
  };
3183
2733
  out.files = fileMatchToJson(fileReading);
3184
- // No --files: one count line for the recorded files no loose file matched,
3185
- // not a line for each of them, and none for a record the pack's own logs
3186
- // disclose and bind. With --files, and in --json, every one is listed.
2734
+ // Without --files, one count line for unmatched recorded files; with
2735
+ // --files or --json, every one is listed.
3187
2736
  fileLines = renderFileMatch(
3188
2737
  fileReading,
3189
2738
  offered !== undefined
@@ -3196,23 +2745,12 @@ async function caseCommand(argv: readonly string[], io: Cli): Promise<number> {
3196
2745
  return code;
3197
2746
  }
3198
2747
 
3199
- function shortTx(tx: string): string {
3200
- if (tx.length <= 16) return tx;
3201
- return `${tx.slice(0, 8)}…${tx.slice(-4)}`;
3202
- }
3203
-
3204
- function shortId(id: string): string {
3205
- if (id.length <= 12) return id;
3206
- return `${id.slice(0, 8)}…`;
3207
- }
3208
-
3209
- // Entry point when run as a binary (not when imported by tests).
2748
+ // Entry point when run as a binary, not when imported.
3210
2749
  const isMain = (() => {
3211
2750
  try {
3212
2751
  if (!process.argv[1]) return false;
3213
- // realpath BOTH sides: when run via a bin symlink (npx / node_modules/.bin/proof
3214
- // -> dist/cli.js), process.argv[1] is the symlink and import.meta.url is the target,
3215
- // so a naive compare skips main(). Resolving both to the real path makes them match.
2752
+ // Resolve both sides: under a bin symlink, argv[1] is the link and
2753
+ // import.meta.url the target.
3216
2754
  return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
3217
2755
  } catch {
3218
2756
  return false;
@@ -3229,6 +2767,6 @@ if (isMain) {
3229
2767
  })
3230
2768
  .catch((e) => {
3231
2769
  process.stderr.write(`fatal: ${escapeControl(e instanceof Error ? e.stack ?? e.message : String(e))}\n`);
3232
- process.exitCode = EXIT_MALFORMED;
2770
+ process.exitCode = EXIT_COULD_NOT_RUN;
3233
2771
  });
3234
2772
  }