@sanning/proof 0.12.0 → 0.13.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 (48) hide show
  1. package/README.md +25 -12
  2. package/dist/agent-proof.d.ts +2 -2
  3. package/dist/agent-proof.js +4 -4
  4. package/dist/case-file.d.ts +126 -0
  5. package/dist/case-file.js +349 -0
  6. package/dist/case.d.ts +173 -26
  7. package/dist/case.js +469 -123
  8. package/dist/cli.d.ts +38 -5
  9. package/dist/cli.js +557 -208
  10. package/dist/crypto.d.ts +1 -1
  11. package/dist/crypto.js +5 -1
  12. package/dist/declaration.d.ts +58 -52
  13. package/dist/declaration.js +256 -33
  14. package/dist/evidence.d.ts +2 -0
  15. package/dist/evidence.js +134 -19
  16. package/dist/exit-codes.d.ts +17 -0
  17. package/dist/exit-codes.js +50 -0
  18. package/dist/grading.d.ts +28 -13
  19. package/dist/grading.js +90 -31
  20. package/dist/index.d.ts +10 -7
  21. package/dist/index.js +13 -6
  22. package/dist/message-parts.d.ts +11 -9
  23. package/dist/message-parts.js +11 -9
  24. package/dist/types.d.ts +1 -0
  25. package/dist/verifier.d.ts +9 -2
  26. package/dist/verifier.js +28 -12
  27. package/dist/zip-reader.d.ts +50 -0
  28. package/dist/zip-reader.js +220 -0
  29. package/package.json +2 -2
  30. package/src/agent-proof.ts +4 -4
  31. package/src/case-file.ts +419 -0
  32. package/src/case.ts +586 -132
  33. package/src/cli.ts +651 -206
  34. package/src/crypto.ts +5 -1
  35. package/src/declaration.ts +248 -52
  36. package/src/evidence.ts +142 -21
  37. package/src/exit-codes.ts +54 -0
  38. package/src/grading.ts +109 -52
  39. package/src/index.ts +58 -2
  40. package/src/message-parts.ts +11 -9
  41. package/src/types.ts +5 -0
  42. package/src/verifier.ts +35 -13
  43. package/src/zip-reader.ts +229 -0
  44. package/vectors/README.md +23 -5
  45. package/vectors/case-file-index.json +668 -0
  46. package/vectors/case-split.json +218 -9
  47. package/vectors/pack-grading.json +68 -4
  48. package/vectors/stamp-on-chain.json +3 -3
package/src/evidence.ts CHANGED
@@ -29,6 +29,7 @@ import {
29
29
  utf8,
30
30
  verifyRsaPssSha256,
31
31
  } from "./crypto.js";
32
+ import { isConformantHandoffRecord, isHandoffNamespace, type HeldRecord } from "./handoff.js";
32
33
  import { leafHash, verifyInclusion } from "./merkle.js";
33
34
  import {
34
35
  MAX_CANONICAL_DEPTH,
@@ -48,7 +49,8 @@ import type { Envelope } from "./types.js";
48
49
  // Only the evidence-bundle major(s) this kernel verifies. A new major is a
49
50
  // deliberate one-entry addition HERE — same fail-closed discipline as the
50
51
  // envelope verifier's ACCEPTED_SPEC_MAJORS.
51
- const ACCEPTED_EVIDENCE_MAJORS = ["sanning.evidence/v1"];
52
+ /** The accepted `sanning.evidence/v1` wrapper majors; exported in both kernels (P-01, 0.13.0). */
53
+ export const ACCEPTED_EVIDENCE_MAJORS: readonly string[] = Object.freeze(["sanning.evidence/v1"]);
52
54
 
53
55
  // The body_type this module knows how to verify structurally. An unknown
54
56
  // body_type is not a hard failure of the WRAPPER (the signature + body_hash
@@ -830,12 +832,16 @@ async function verifyBundle(
830
832
  const b = bundle as EvidenceBundle;
831
833
  const specVersionOk = isEvidenceMajorAccepted(b.spec_version);
832
834
  if (!specVersionOk) {
833
- return malformed(`unsupported evidence spec_version: ${JSON.stringify(b.spec_version)}`, false);
835
+ // `?? null`: an ABSENT field is spelled `null`, as the Python twin's
836
+ // `_json_str` spells it. `JSON.stringify(undefined)` is `undefined`, and
837
+ // the two kernels printed two different sentences for one bundle until
838
+ // `verify --json` compared them byte for byte (0.13.0).
839
+ return malformed(`unsupported evidence spec_version: ${JSON.stringify(b.spec_version ?? null)}`, false);
834
840
  }
835
841
  if (typeof b.signature_alg === "string" && b.signature_alg !== "ed25519") {
836
842
  // The reference verifier implements ed25519 only (evidence-bundle §2 +
837
843
  // decision #2); other algs are a deliberate future addition.
838
- return malformed(`unsupported signature_alg: ${JSON.stringify(b.signature_alg)}`, true);
844
+ return malformed(`unsupported signature_alg: ${JSON.stringify(b.signature_alg ?? null)}`, true);
839
845
  }
840
846
  if (typeof b.public_key !== "string" || typeof b.signature !== "string") {
841
847
  return malformed("evidence bundle is missing public_key/signature", true);
@@ -885,7 +891,7 @@ async function verifyBundle(
885
891
  const { signature: _sig, co_signatures: _co, ...bundleForSig } = b as EvidenceBundle & {
886
892
  co_signatures?: unknown;
887
893
  };
888
- signatureOk = await ed25519Verify(b.signature, utf8(jcs(bundleForSig)), b.public_key);
894
+ signatureOk = await ed25519Verify(utf8(jcs(bundleForSig)), b.signature, b.public_key);
889
895
  if (!signatureOk) errors.push("wrapper Ed25519 signature verification failed");
890
896
  } catch (e) {
891
897
  errors.push(`wrapper signature verification error${thrownDetail(e)}`);
@@ -1135,7 +1141,7 @@ function parseAnchorRef(
1135
1141
  }
1136
1142
  const o = raw as Record<string, unknown>;
1137
1143
  if (typeof o.kind !== "string" || !/^[a-z0-9][a-z0-9._-]*$/.test(o.kind)) {
1138
- errors.push(`anchor_ref.kind is missing or malformed: ${JSON.stringify(o.kind)}`);
1144
+ errors.push(`anchor_ref.kind is missing or malformed: ${JSON.stringify(o.kind ?? null)}`);
1139
1145
  return { ref: null, malformed: true };
1140
1146
  }
1141
1147
  if (typeof o.id !== "string" || o.id === "") {
@@ -1711,21 +1717,61 @@ async function verifyDisclosedContent(
1711
1717
  const evErrors: string[] = [];
1712
1718
  let contentOk: boolean | null = null;
1713
1719
 
1720
+ // WHICH hash in the record is decided by the record's STRUCTURE
1721
+ // (evidence-bundle §5.1 v1.19, A20). A record `envelope-spec.md` §4.1
1722
+ // governs (`isHandoffNamespace`) commits to its message through
1723
+ // `event.message_hash`, SHA-256 of `JCS(message object)`, and has no
1724
+ // `content_hash`, so until A20 its disclosure bound to nothing and an honest
1725
+ // hand-off pack could never grade `verified`. For such a record the committed
1726
+ // hash is `event.message_hash` and nothing else: not a `content_hash` it also
1727
+ // carries, and not the envelope's. It binds only when the record is a
1728
+ // CONFORMANT hand-off record under the key that signed it
1729
+ // (`isConformantHandoffRecord`) and the value is 64 characters of lowercase
1730
+ // hex. Anything short of that commits nothing this reader will bind to, so
1731
+ // the reading is undetermined and never true: a record dressed as a hand-off
1732
+ // record gains no binding from the dress, and a record outside §4.1 keeps its
1733
+ // `content_hash` exactly as before, whatever `message_hash` field it writes.
1734
+ // The Python twin is `evidence._evaluate_content`.
1714
1735
  const committed: string[] = [];
1715
1736
  let recordContentHash: string | undefined;
1737
+ let governed = false;
1738
+ let handoffHash: string | undefined;
1716
1739
  if (eventBytes !== undefined) {
1717
1740
  try {
1718
- const record = jcsParse(eventBytes) as {
1741
+ const parsed = jcsParse(eventBytes);
1742
+ const record = parsed as {
1719
1743
  event?: { content_hash?: unknown };
1720
- };
1721
- const ch = record.event?.content_hash;
1744
+ } | null;
1745
+ const ch = record?.event?.content_hash;
1722
1746
  if (typeof ch === "string" && ch) recordContentHash = ch.toLowerCase();
1747
+ if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
1748
+ const envelope = ev.envelope;
1749
+ const held: HeldRecord = {
1750
+ publicKey:
1751
+ envelope !== null && typeof envelope === "object" && !Array.isArray(envelope)
1752
+ ? ((envelope as { public_key?: unknown }).public_key as string)
1753
+ : (undefined as unknown as string),
1754
+ record: parsed as HeldRecord["record"],
1755
+ ref: "",
1756
+ };
1757
+ if (isHandoffNamespace(held)) {
1758
+ governed = true;
1759
+ const event = (parsed as { event?: unknown }).event;
1760
+ const mh =
1761
+ event !== null && typeof event === "object" && !Array.isArray(event)
1762
+ ? (event as { message_hash?: unknown }).message_hash
1763
+ : undefined;
1764
+ if (isConformantHandoffRecord(held) && isLowerHex(mh, 64)) handoffHash = mh;
1765
+ }
1766
+ }
1723
1767
  } catch {
1724
1768
  // record not JSON — the payload binding already reflects that; content
1725
1769
  // stays undetermined rather than throwing.
1726
1770
  }
1727
1771
  }
1728
- if (recordContentHash !== undefined) {
1772
+ if (governed) {
1773
+ if (handoffHash !== undefined) committed.push(handoffHash);
1774
+ } else if (recordContentHash !== undefined) {
1729
1775
  committed.push(recordContentHash);
1730
1776
  } else if (ev.envelope && typeof ev.envelope === "object") {
1731
1777
  // Promoted fallback. Guarded: contentHashes() dereferences env.payload,
@@ -1749,16 +1795,21 @@ async function verifyDisclosedContent(
1749
1795
  contentOk = null;
1750
1796
  if (disclosed !== undefined && committed.length === 0) {
1751
1797
  evErrors.push(
1752
- "disclosed content present but no committed content_hash to bind to " +
1753
- "(record withheld or record has no event.content_hash) -- undetermined",
1798
+ governed
1799
+ ? "disclosed content present but this hand-off record commits no " +
1800
+ "message_hash a reader can bind to (not a conformant hand-off " +
1801
+ "record, or message_hash is not 64 lowercase hex) -- undetermined"
1802
+ : "disclosed content present but no committed content_hash to bind to " +
1803
+ "(record withheld or record has no event.content_hash) -- undetermined",
1754
1804
  );
1755
1805
  }
1756
1806
  } else {
1757
1807
  const got = await sha256Hex(disclosed);
1758
1808
  contentOk = committed.includes(got);
1759
1809
  if (!contentOk) {
1810
+ const fieldName = governed ? "message_hash" : "content_hash";
1760
1811
  evErrors.push(
1761
- "disclosed content does not match the committed content_hash: " +
1812
+ `disclosed content does not match the committed ${fieldName}: ` +
1762
1813
  `sha256(disclosed)=${got} committed=${committed.join("|")}`,
1763
1814
  );
1764
1815
  }
@@ -1882,7 +1933,7 @@ async function verifyStampEntry(
1882
1933
  // reader's dispatch key and it said v1; honouring the other spelling is
1883
1934
  // how a reader half-parses a shape it does not know.
1884
1935
  errors.push(
1885
- `unsupported stamp_version ${JSON.stringify(body.stamp_version)} in a ` +
1936
+ `unsupported stamp_version ${JSON.stringify(body.stamp_version ?? null)} in a ` +
1886
1937
  `${STAMP_TRACE_BODY_TYPE} body (expected ${STAMP_BODY_VERSION})`,
1887
1938
  );
1888
1939
  return { result: base, witnessedHex };
@@ -2429,8 +2480,8 @@ async function verifyStampEvidence(
2429
2480
  * synthesis exists to drive `gatewayUnavailable`, which is what downgrades that
2430
2481
  * run to `partial`. Here §5.2.4 already downgrades an unwitnessed stamp to
2431
2482
  * `partial` on its own, with a better error. Copying the synthesis would add
2432
- * nothing to the status and would silently move the CLI exit code from 0 to 3
2433
- * for an unwitnessed stamp — changing what an auditor's script does, based on
2483
+ * nothing to the status, which is `partial` either way, and would add an
2484
+ * unreachable-gateway note for a network event that never happened, based on
2434
2485
  * nothing but whether the caller happened to pass a gateway list.
2435
2486
  */
2436
2487
  async function refetchStamps(
@@ -2840,8 +2891,11 @@ async function verifyExportBody(
2840
2891
  // step-5 erratum flagged in the slice report.
2841
2892
  let verdictAgreementOk = false;
2842
2893
  try {
2843
- const cachedProjection = jcs(projectVerdictForAgreement(body.kernel_verdict));
2844
- const freshProjection = jcs(projectVerdictForAgreement(verdict));
2894
+ const cached = projectVerdictForAgreement(body.kernel_verdict);
2895
+ const fresh = projectVerdictForAgreement(verdict);
2896
+ toleratePreA20Content(cached, fresh, sourceEventsGoverned(inline));
2897
+ const cachedProjection = jcs(cached);
2898
+ const freshProjection = jcs(fresh);
2845
2899
  verdictAgreementOk = cachedProjection === freshProjection;
2846
2900
  if (!verdictAgreementOk) {
2847
2901
  errors.push(
@@ -2874,8 +2928,8 @@ async function verifyExportBody(
2874
2928
  ) {
2875
2929
  status = "failed";
2876
2930
  } else if (sourceResult.status === "partial") {
2877
- // Undetermined source (withheld record → exit 0; gateway-unreachable →
2878
- // exit 3 via the propagated "unreachable" note). Never a failure.
2931
+ // Undetermined source (a withheld record, or an unreachable gateway):
2932
+ // exit 3 either way since 0.13.0. Never a failure.
2879
2933
  status = "partial";
2880
2934
  } else {
2881
2935
  status = "verified";
@@ -3064,7 +3118,7 @@ async function verifyAttestation(
3064
3118
  if (rec.signature_alg !== "rsa-pss-sha256") {
3065
3119
  return {
3066
3120
  result,
3067
- malformed: `unsupported attestation signature_alg: ${JSON.stringify(rec.signature_alg)}`,
3121
+ malformed: `unsupported attestation signature_alg: ${JSON.stringify(rec.signature_alg ?? null)}`,
3068
3122
  };
3069
3123
  }
3070
3124
  const pub = rec.public_key as { kty?: unknown; n?: unknown; e?: unknown } | null;
@@ -3266,7 +3320,11 @@ function eventVerdictStatus(e: EventResult): string {
3266
3320
  // nullish normalization) — a structural difference surfaces as disagreement,
3267
3321
  // which is the correct tamper signal. Works on both the cached snake_case JSON
3268
3322
  // and the freshly built VerdictObject.
3269
- function projectVerdictForAgreement(v: unknown): unknown {
3323
+ function projectVerdictForAgreement(v: unknown): {
3324
+ schema_version: string | null;
3325
+ events: { content_ok: boolean | null }[];
3326
+ checkpoints: unknown[];
3327
+ } {
3270
3328
  const o = (v ?? {}) as VerdictObject;
3271
3329
  const events = Array.isArray(o.events) ? o.events : [];
3272
3330
  const checkpoints = Array.isArray(o.checkpoints) ? o.checkpoints : [];
@@ -3304,6 +3362,69 @@ function projectVerdictForAgreement(v: unknown): unknown {
3304
3362
  };
3305
3363
  }
3306
3364
 
3365
+ // Per event of an export's inline `sanning.anchor.trace/v3` source, in order:
3366
+ // whether `envelope-spec.md` §4.1 governs its record. Decoded the way the
3367
+ // content binding decodes it, so an event whose record is withheld, not hex or
3368
+ // not JCS is not governed here either. The Python twin is
3369
+ // `evidence._source_events_governed`.
3370
+ function sourceEventsGoverned(source: unknown): boolean[] {
3371
+ if (source === null || typeof source !== "object" || Array.isArray(source)) return [];
3372
+ const s = source as { body_type?: unknown; body?: unknown };
3373
+ if (s.body_type !== ANCHOR_TRACE_BODY_TYPE) return [];
3374
+ const body = s.body as { events?: unknown } | null | undefined;
3375
+ const events =
3376
+ body !== null && typeof body === "object" && !Array.isArray(body) ? body.events : undefined;
3377
+ if (!Array.isArray(events)) return [];
3378
+ return events.map((raw: unknown) => {
3379
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return false;
3380
+ const ev = raw as { event_bytes?: unknown; envelope?: unknown };
3381
+ if (typeof ev.event_bytes !== "string") return false;
3382
+ try {
3383
+ const parsed = jcsParse(hexToBytes(ev.event_bytes));
3384
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return false;
3385
+ const envelope = ev.envelope;
3386
+ const held: HeldRecord = {
3387
+ publicKey:
3388
+ envelope !== null && typeof envelope === "object" && !Array.isArray(envelope)
3389
+ ? ((envelope as { public_key?: unknown }).public_key as string)
3390
+ : (undefined as unknown as string),
3391
+ record: parsed as HeldRecord["record"],
3392
+ ref: "",
3393
+ };
3394
+ return isHandoffNamespace(held);
3395
+ } catch {
3396
+ return false;
3397
+ }
3398
+ });
3399
+ }
3400
+
3401
+ // The one difference §5 step 5 does not read as a disagreement
3402
+ // (evidence-export.md, evidence-bundle.md v1.19, A20): a cached `content_ok` of
3403
+ // `null` against a recomputed `true`, on an event whose record §4.1 governs. A
3404
+ // kernel before A20 could not bind a hand-off record's disclosed message, so an
3405
+ // export it composed claims less than the recompute proves, and reading that
3406
+ // as tamper failed an honest export. Only that direction: a cached `true`
3407
+ // against a recomputed `false` or `null`, `null` against `false`, and any
3408
+ // difference on a record outside §4.1 still disagree. Edits `fresh` in place
3409
+ // to match `cached` on those events. The Python twin is
3410
+ // `evidence._tolerate_pre_a20_content`.
3411
+ function toleratePreA20Content(
3412
+ cached: { events: { content_ok: boolean | null }[] },
3413
+ fresh: { events: { content_ok: boolean | null }[] },
3414
+ governed: boolean[],
3415
+ ): void {
3416
+ const n = Math.min(cached.events.length, fresh.events.length);
3417
+ for (let i = 0; i < n; i++) {
3418
+ if (
3419
+ governed[i] === true &&
3420
+ cached.events[i]!.content_ok === null &&
3421
+ fresh.events[i]!.content_ok === true
3422
+ ) {
3423
+ fresh.events[i]!.content_ok = null;
3424
+ }
3425
+ }
3426
+ }
3427
+
3307
3428
  function summarizeExport(
3308
3429
  status: EvidenceStatus,
3309
3430
  source: EvidenceBundleResult,
@@ -0,0 +1,54 @@
1
+ // One exit-code table for every verb (0.13.0).
2
+ //
3
+ // Both commands, `npx @sanning/proof` and `sanning-proof`, map a verdict word to
4
+ // an exit code through this one table, for `verify`, `case` and `export` alike,
5
+ // and every verb's `--help` prints `EXIT_CODES_HELP` byte for byte. The twin is
6
+ // `src/sanning_proof/exit_codes.py`; the specification is `evidence-export.md`
7
+ // §5.
8
+ //
9
+ // Until 0.13.0 `verify` exited 0 on `partial` unless a gateway was unreachable,
10
+ // while `case` exited 3 on it, so a CI gate reading `verify`'s exit code read a
11
+ // stamp witnessed by nobody as verified. Every word that means "could not be
12
+ // settled" now exits 3, which is the code's name: undetermined.
13
+
14
+ export const EXIT_VERIFIED = 0;
15
+ export const EXIT_FAILED = 1;
16
+ export const EXIT_MALFORMED = 2;
17
+ export const EXIT_UNDETERMINED = 3;
18
+ export const EXIT_MIXED = 4;
19
+
20
+ /**
21
+ * The exit code of each of the eight verdict words (`evidence-bundle.md` §3).
22
+ * `undetermined` is the name of 3, and never a word here.
23
+ */
24
+ export const EXIT_CODES: Readonly<Record<string, number>> = Object.freeze({
25
+ verified: EXIT_VERIFIED,
26
+ failed: EXIT_FAILED,
27
+ malformed: EXIT_MALFORMED,
28
+ partial: EXIT_UNDETERMINED,
29
+ pending: EXIT_UNDETERMINED,
30
+ incomplete: EXIT_UNDETERMINED,
31
+ not_found: EXIT_UNDETERMINED,
32
+ mixed: EXIT_MIXED,
33
+ });
34
+
35
+ /** What every verb's help prints, identical in both commands. */
36
+ export const EXIT_CODES_HELP = [
37
+ "Exit codes, one table for every verb:",
38
+ " 0 verified, or the export was written",
39
+ " 1 failed",
40
+ " 2 usage or malformed",
41
+ " 3 undetermined: partial, pending, incomplete or not_found",
42
+ " 4 mixed: more than one outcome, none failed; read the exceptions",
43
+ ].join("\n");
44
+
45
+ /**
46
+ * The exit code of a verdict word. A word outside the eight is a verdict
47
+ * nothing can render, so it exits 2, never 0.
48
+ */
49
+ export function exitCodeFor(verdict: unknown): number {
50
+ if (typeof verdict === "string" && Object.prototype.hasOwnProperty.call(EXIT_CODES, verdict)) {
51
+ return EXIT_CODES[verdict]!;
52
+ }
53
+ return EXIT_MALFORMED;
54
+ }
package/src/grading.ts CHANGED
@@ -41,13 +41,13 @@
41
41
  // presents a different number of records than a checkpoint
42
42
  // signed for — UNKNOWN
43
43
  //
44
- // `incomplete` is the surface's word for undetermined, and it is deliberately
45
- // NOT `verdict.status` from `evidence-bundle.md` §3. That enum grades a signed
46
- // bundle; this one grades a PACK — a strictly larger object, the bundle plus
47
- // whatever bytes travelled beside it. A pack can be incomplete while the bundle
48
- // it carries is impeccable, which is exactly the case that used to render green
49
- // over zero hashed bytes. Widening §3 to carry `incomplete` would be a spec
50
- // change; keeping the two enums distinct is not.
44
+ // `incomplete` is one of the eight words of `evidence-bundle.md` §3 (v1.17,
45
+ // A18), and it is a PACK verdict, never a bundle status. The bundle status
46
+ // grades a signed bundle; this grades a PACK — a strictly larger object, the
47
+ // bundle plus whatever bytes travelled beside it. A pack can be incomplete
48
+ // while the bundle it carries is impeccable, which is exactly the case that
49
+ // used to render green over zero hashed bytes. One vocabulary, two layers,
50
+ // each issuing the subset it can reach.
51
51
  //
52
52
  // `recordsVerified` counts CONTENT-BOUND records only. The kernel's own
53
53
  // `event.ok` tolerates undisclosed content by design — a bundle may legitimately
@@ -86,15 +86,17 @@ import { jcsParse } from "./verifier.js";
86
86
  // A superset of `EvidenceStatus`: the four the kernel can return, plus the
87
87
  // pack-level `incomplete`, plus `pending` from the shared §3 vocabulary so a
88
88
  // kernel headline this module does not downgrade can pass through unchanged.
89
- export type PackVerdict =
90
- | "verified"
91
- | "partial"
92
- | "pending"
93
- | "not_found"
94
- | "mixed"
95
- | "failed"
96
- | "malformed"
97
- | "incomplete";
89
+ export const PACK_VERDICTS = [
90
+ "verified",
91
+ "partial",
92
+ "pending",
93
+ "not_found",
94
+ "mixed",
95
+ "failed",
96
+ "malformed",
97
+ "incomplete",
98
+ ] as const;
99
+ export type PackVerdict = (typeof PACK_VERDICTS)[number];
98
100
 
99
101
  // ---- Inputs ------------------------------------------------------------------
100
102
 
@@ -109,7 +111,17 @@ export type PackVerdict =
109
111
  */
110
112
  export interface GradeInput {
111
113
  status: string | null | undefined;
112
- events: { ok: boolean; contentOk: boolean | null }[];
114
+ /**
115
+ * `eventId` and `payloadBindingOk` are read by rule version 6 alone (A22):
116
+ * an input that does not carry both is never withheld by its declaration,
117
+ * so an adapter written for rule version 5 keeps the stricter reading.
118
+ */
119
+ events: {
120
+ ok: boolean;
121
+ contentOk: boolean | null;
122
+ eventId?: string;
123
+ payloadBindingOk?: boolean | null;
124
+ }[];
113
125
  }
114
126
 
115
127
  /**
@@ -144,21 +156,23 @@ export type CompletenessNote =
144
156
 
145
157
  // ---- Output ------------------------------------------------------------------
146
158
 
147
- export type PackGradeCauseKind =
148
- | "records-omitted"
149
- | "records-surplus"
150
- | "leaf-count-unreadable"
151
- | "no-disclosure"
152
- | "missing-disclosure"
153
- | "unreadable-disclosure"
154
- | "unaccounted-disclosure"
155
- | "declaration-deviation"
159
+ export const PACK_GRADE_CAUSE_KINDS = [
160
+ "records_omitted",
161
+ "records_surplus",
162
+ "leaf_count_unreadable",
163
+ "no_disclosure",
164
+ "missing_disclosure",
165
+ "unreadable_disclosure",
166
+ "unaccounted_disclosure",
167
+ "declaration_deviation",
156
168
  /**
157
169
  * The pack and the UNSIGNED content handed in beside it do not line up
158
170
  * (proof#147). A cause, so the pack cannot read clean over it and a reader
159
171
  * is told which record; NOT a deviation, so it never reaches `failed`.
160
172
  */
161
- | "declaration-content-dependent";
173
+ "declaration_content_dependent",
174
+ ] as const;
175
+ export type PackGradeCauseKind = (typeof PACK_GRADE_CAUSE_KINDS)[number];
162
176
 
163
177
  /**
164
178
  * One reason a pack is not `verified`, as data.
@@ -186,8 +200,8 @@ export interface PackGradeCause {
186
200
  reason?: string;
187
201
  /**
188
202
  * Which deviation from the pack's own declaration, for
189
- * `declaration-deviation`, or which content-dependent finding, for
190
- * `declaration-content-dependent`.
203
+ * `declaration_deviation`, or which content-dependent finding, for
204
+ * `declaration_content_dependent`.
191
205
  */
192
206
  deviation?: DeclarationDeviationKind | DeclarationContentKind;
193
207
  }
@@ -205,6 +219,17 @@ export interface PackGrade {
205
219
  * check was passed, which is the pre-§5.4 call.
206
220
  */
207
221
  declaration?: DeclarationVerdict;
222
+ /**
223
+ * Records WITHHELD BY THEIR DECLARATION (rule version 6, evidence-bundle
224
+ * 5.4.5 v1.20, A22): a readable declaration lists them in
225
+ * `declaredWithheld`, their `ok` is true, their `contentOk` is `null` and
226
+ * their `payloadBindingOk` is true. Never counted in `recordsVerified`: no
227
+ * byte of their content was hashed, and a surface MUST NOT print them as
228
+ * verified records.
229
+ */
230
+ recordsWithheld: number;
231
+ /** Their `event_id`s, in bundle order. */
232
+ withheldEventIds: string[];
208
233
  }
209
234
 
210
235
  // ---- Reading a bundle --------------------------------------------------------
@@ -522,7 +547,20 @@ export function gradeCauses(
522
547
  account: DisclosureAccount,
523
548
  notes: readonly CompletenessNote[],
524
549
  declaration?: DeclarationCheck | null,
550
+ withheld: readonly string[] = [],
525
551
  ): PackGradeCause[] {
552
+ // The `event_id`s WITHHELD BY THEIR DECLARATION (rule version 6) are taken
553
+ // out of `expected` and `missing` before the disclosure causes are computed,
554
+ // so a pack whose every record its declaration withholds earns neither
555
+ // `no_disclosure` nor `missing_disclosure`.
556
+ if (withheld.length > 0) {
557
+ const gone = new Set(withheld);
558
+ account = {
559
+ ...account,
560
+ expected: account.expected.filter((e) => !gone.has(e)),
561
+ missing: account.missing.filter((e) => !gone.has(e)),
562
+ };
563
+ }
526
564
  const causes: PackGradeCause[] = [];
527
565
 
528
566
  // What the pack CLAIMED to hold comes first, because it frames everything
@@ -532,7 +570,7 @@ export function gradeCauses(
532
570
  // four ways can never be reported as one vague shortfall.
533
571
  for (const deviation of declaration?.deviations ?? []) {
534
572
  causes.push({
535
- kind: "declaration-deviation",
573
+ kind: "declaration_deviation",
536
574
  deviation: deviation.kind,
537
575
  ...(deviation.eventId === undefined ? {} : { firstEventId: deviation.eventId }),
538
576
  reason: renderDeviation(deviation),
@@ -544,7 +582,7 @@ export function gradeCauses(
544
582
  // deviation, so it never reaches `failed`.
545
583
  for (const finding of declaration?.contentFindings ?? []) {
546
584
  causes.push({
547
- kind: "declaration-content-dependent",
585
+ kind: "declaration_content_dependent",
548
586
  deviation: finding.kind,
549
587
  ...(finding.eventId === undefined ? {} : { firstEventId: finding.eventId }),
550
588
  reason: renderContentFinding(finding),
@@ -554,7 +592,7 @@ export function gradeCauses(
554
592
  for (const note of notes) {
555
593
  if (note.kind === "omitted") {
556
594
  causes.push({
557
- kind: "records-omitted",
595
+ kind: "records_omitted",
558
596
  checkpointRef: note.checkpointRef,
559
597
  signed: note.signed,
560
598
  presented: note.presented,
@@ -562,14 +600,14 @@ export function gradeCauses(
562
600
  });
563
601
  } else if (note.kind === "surplus") {
564
602
  causes.push({
565
- kind: "records-surplus",
603
+ kind: "records_surplus",
566
604
  checkpointRef: note.checkpointRef,
567
605
  signed: note.signed,
568
606
  presented: note.presented,
569
607
  count: note.presented - note.signed,
570
608
  });
571
609
  } else {
572
- causes.push({ kind: "leaf-count-unreadable", checkpointRef: note.checkpointRef });
610
+ causes.push({ kind: "leaf_count_unreadable", checkpointRef: note.checkpointRef });
573
611
  }
574
612
  }
575
613
 
@@ -578,10 +616,10 @@ export function gradeCauses(
578
616
  // reader deserves to be told the pack disclosed nothing rather than counting
579
617
  // N absences.
580
618
  if (account.expected.length > 0 && account.present.length === 0 && account.unreadable.length === 0) {
581
- causes.push({ kind: "no-disclosure", count: account.expected.length, of: account.expected.length });
619
+ causes.push({ kind: "no_disclosure", count: account.expected.length, of: account.expected.length });
582
620
  } else if (account.missing.length > 0) {
583
621
  causes.push({
584
- kind: "missing-disclosure",
622
+ kind: "missing_disclosure",
585
623
  count: account.missing.length,
586
624
  of: account.expected.length,
587
625
  ...(account.missing[0] === undefined ? {} : { firstEventId: account.missing[0] }),
@@ -589,12 +627,12 @@ export function gradeCauses(
589
627
  }
590
628
 
591
629
  for (const u of account.unreadable) {
592
- causes.push({ kind: "unreadable-disclosure", firstEventId: u.eventId, reason: u.reason });
630
+ causes.push({ kind: "unreadable_disclosure", firstEventId: u.eventId, reason: u.reason });
593
631
  }
594
632
 
595
633
  if (account.extra.length > 0) {
596
634
  causes.push({
597
- kind: "unaccounted-disclosure",
635
+ kind: "unaccounted_disclosure",
598
636
  count: account.extra.length,
599
637
  ...(account.extra[0] === undefined ? {} : { firstEventId: account.extra[0] }),
600
638
  });
@@ -614,7 +652,8 @@ export function gradeCauses(
614
652
  * the type and renders the verdict verbatim would then print the one word
615
653
  * `evidence-bundle.md` §6 forbids, over a pack this rule never graded.
616
654
  *
617
- * So: the six states of the §3 vocabulary pass through, and ANYTHING ELSE is
655
+ * So: the five words of the §3 vocabulary that a bundle status can pass up to
656
+ * a pack pass through, and ANYTHING ELSE is
618
657
  * `incomplete`. A verdict we cannot interpret is a check we could not run, and
619
658
  * the answer to that is UNKNOWN — never a pass, and never an accusation.
620
659
  * (`failed` and `malformed` are handled before this is reached.)
@@ -637,8 +676,9 @@ function passThrough(status: string | null | undefined): PackVerdict {
637
676
  * The verdict a surface may print, and the counts it may print beside it.
638
677
  *
639
678
  * The invariant, stated so a mutation test can kill it: `verified` is reachable
640
- * ONLY when every record's supplied bytes bound to its own commitment and
641
- * nothing is missing, unreadable, surplus or omitted.
679
+ * ONLY when every record's supplied bytes bound to its own commitment, or
680
+ * (rule version 6) the record is withheld by its declaration, and nothing is
681
+ * missing, unreadable, surplus or omitted.
642
682
  *
643
683
  * Precedence: malformed > failed > incomplete > the kernel's own headline.
644
684
  * A hard verdict wins — "the bytes are absent" is never reported as failure
@@ -696,8 +736,23 @@ export function gradePack(
696
736
  const events = Array.isArray(result?.events) ? result.events : [];
697
737
  const recordsVerified = events.filter((e) => e.ok && e.contentOk === true).length;
698
738
  const recordsFailed = events.filter((e) => e.ok === false || e.contentOk === false).length;
699
- const recordsUndetermined = events.length - recordsVerified - recordsFailed;
700
- const causes = gradeCauses(account, notes, declaration);
739
+ // WITHHELD BY ITS DECLARATION (rule version 6, 5.4.5 v1.20, A22, D5). All
740
+ // four conditions, each on its own. `declaredWithheld` is empty for an
741
+ // absent, unreadable or malformed declaration.
742
+ const declaredWithheld = new Set(declaration?.declaredWithheld ?? []);
743
+ const withheldEventIds = events
744
+ .filter(
745
+ (e) =>
746
+ typeof e.eventId === "string" &&
747
+ declaredWithheld.has(e.eventId) &&
748
+ e.ok === true &&
749
+ e.contentOk === null &&
750
+ e.payloadBindingOk === true,
751
+ )
752
+ .map((e) => e.eventId as string);
753
+ const recordsWithheld = withheldEventIds.length;
754
+ const recordsUndetermined = events.length - recordsVerified - recordsFailed - recordsWithheld;
755
+ const causes = gradeCauses(account, notes, declaration, withheldEventIds);
701
756
 
702
757
  let verdict: PackVerdict;
703
758
  if (result?.status === "malformed") verdict = "malformed";
@@ -714,6 +769,8 @@ export function gradePack(
714
769
  recordsFailed,
715
770
  recordsUndetermined,
716
771
  causes,
772
+ recordsWithheld,
773
+ withheldEventIds,
717
774
  };
718
775
  if (declaration != null) grade.declaration = declaration.verdict;
719
776
  return grade;
@@ -733,31 +790,31 @@ export function gradePack(
733
790
  */
734
791
  export function renderCause(cause: PackGradeCause, subject = "this pack"): string {
735
792
  switch (cause.kind) {
736
- case "records-omitted":
793
+ case "records_omitted":
737
794
  return (
738
795
  `checkpoint ${cause.checkpointRef} commits to ${cause.signed} record(s); ${subject} presents ` +
739
796
  `${cause.presented} — ${cause.count} record(s) OMITTED`
740
797
  );
741
- case "records-surplus":
798
+ case "records_surplus":
742
799
  return (
743
800
  `checkpoint ${cause.checkpointRef} commits to ${cause.signed} record(s); ${subject} presents ` +
744
801
  `${cause.presented} — more records than the signed tree accounts for`
745
802
  );
746
- case "leaf-count-unreadable":
803
+ case "leaf_count_unreadable":
747
804
  return `checkpoint ${cause.checkpointRef} has no readable signed leaf_count — completeness is UNKNOWN`;
748
- case "no-disclosure":
805
+ case "no_disclosure":
749
806
  return (
750
807
  `no event bytes were disclosed — content binding is UNKNOWN for all ${cause.count} ` +
751
808
  `record(s), not verified`
752
809
  );
753
- case "missing-disclosure":
810
+ case "missing_disclosure":
754
811
  return (
755
812
  `${cause.count} of ${cause.of} record(s) disclose no bytes (first: ${cause.firstEventId}) — ` +
756
813
  `content binding is UNKNOWN, not verified`
757
814
  );
758
- case "unreadable-disclosure":
815
+ case "unreadable_disclosure":
759
816
  return `the disclosure for ${cause.firstEventId} is unreadable: ${cause.reason}`;
760
- case "unaccounted-disclosure":
817
+ case "unaccounted_disclosure":
761
818
  return (
762
819
  `${cause.count} disclosed file(s) are not accounted for by any record in this bundle ` +
763
820
  `(first: ${cause.firstEventId})`
@@ -766,9 +823,9 @@ export function renderCause(cause: PackGradeCause, subject = "this pack"): strin
766
823
  // here on `reason`. Re-deriving the sentence from the cause fields would
767
824
  // put the declaration's wording in two places, and one of the two would be
768
825
  // edited alone.
769
- case "declaration-deviation":
826
+ case "declaration_deviation":
770
827
  return `this pack deviates from its own declaration: ${cause.reason}`;
771
- case "declaration-content-dependent":
828
+ case "declaration_content_dependent":
772
829
  // Named as what the reader could not establish, never as what the
773
830
  // holder did: the bytes it turns on are unsigned and whoever assembled
774
831
  // the case chose them (proof#147).