@sanning/proof 0.12.0 → 0.14.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 (59) 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 +353 -0
  6. package/dist/case.d.ts +217 -26
  7. package/dist/case.js +535 -124
  8. package/dist/cli.d.ts +64 -8
  9. package/dist/cli.js +799 -219
  10. package/dist/compose.js +6 -1
  11. package/dist/crypto.d.ts +1 -1
  12. package/dist/crypto.js +5 -1
  13. package/dist/declaration.d.ts +58 -52
  14. package/dist/declaration.js +256 -33
  15. package/dist/evidence.d.ts +38 -0
  16. package/dist/evidence.js +238 -22
  17. package/dist/exit-codes.d.ts +17 -0
  18. package/dist/exit-codes.js +50 -0
  19. package/dist/file-match.d.ts +105 -0
  20. package/dist/file-match.js +374 -0
  21. package/dist/grading.d.ts +28 -13
  22. package/dist/grading.js +90 -31
  23. package/dist/index.d.ts +14 -7
  24. package/dist/index.js +20 -6
  25. package/dist/message-parts.d.ts +18 -10
  26. package/dist/message-parts.js +25 -15
  27. package/dist/types.d.ts +1 -0
  28. package/dist/verifier.d.ts +63 -4
  29. package/dist/verifier.js +223 -15
  30. package/dist/witness-cache.d.ts +85 -0
  31. package/dist/witness-cache.js +264 -0
  32. package/dist/zip-reader.d.ts +50 -0
  33. package/dist/zip-reader.js +220 -0
  34. package/package.json +2 -2
  35. package/src/agent-proof.ts +4 -4
  36. package/src/case-file.ts +422 -0
  37. package/src/case.ts +671 -131
  38. package/src/cli.ts +932 -217
  39. package/src/compose.ts +8 -1
  40. package/src/crypto.ts +5 -1
  41. package/src/declaration.ts +248 -52
  42. package/src/evidence.ts +257 -23
  43. package/src/exit-codes.ts +54 -0
  44. package/src/file-match.ts +461 -0
  45. package/src/grading.ts +109 -52
  46. package/src/index.ts +101 -2
  47. package/src/message-parts.ts +25 -15
  48. package/src/types.ts +5 -0
  49. package/src/verifier.ts +225 -16
  50. package/src/witness-cache.ts +306 -0
  51. package/src/zip-reader.ts +229 -0
  52. package/vectors/README.md +66 -8
  53. package/vectors/case-file-index.json +668 -0
  54. package/vectors/case-split.json +218 -9
  55. package/vectors/file-match.json +1808 -0
  56. package/vectors/jcs-reader.json +82 -3
  57. package/vectors/pack-grading.json +68 -4
  58. package/vectors/stamp-on-chain.json +20 -5
  59. package/vectors/witness-cache.json +1304 -0
package/src/evidence.ts CHANGED
@@ -29,11 +29,14 @@ 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 {
35
+ DuplicateKeyError,
34
36
  MAX_CANONICAL_DEPTH,
35
37
  contentHashes,
36
38
  exceedsDepth,
39
+ isRfc3339UtcInstant,
37
40
  jcs,
38
41
  jcsParse,
39
42
  verifyEnvelope,
@@ -48,7 +51,8 @@ import type { Envelope } from "./types.js";
48
51
  // Only the evidence-bundle major(s) this kernel verifies. A new major is a
49
52
  // deliberate one-entry addition HERE — same fail-closed discipline as the
50
53
  // envelope verifier's ACCEPTED_SPEC_MAJORS.
51
- const ACCEPTED_EVIDENCE_MAJORS = ["sanning.evidence/v1"];
54
+ /** The accepted `sanning.evidence/v1` wrapper majors; exported in both kernels (P-01, 0.13.0). */
55
+ export const ACCEPTED_EVIDENCE_MAJORS: readonly string[] = Object.freeze(["sanning.evidence/v1"]);
52
56
 
53
57
  // The body_type this module knows how to verify structurally. An unknown
54
58
  // body_type is not a hard failure of the WRAPPER (the signature + body_hash
@@ -514,6 +518,12 @@ export interface EvidenceBundleResult {
514
518
  // Absent for every other body — additive, backward-compatible.
515
519
  stamps?: StampResult[];
516
520
  errors: string[];
521
+ // Informational only: never read, never gated on, never moves `status` or
522
+ // the CLI exit code. Carries `CO_SIGNATURES_NOTE` where the wrapper's
523
+ // reserved `co_signatures` field is present with at least one entry
524
+ // (evidence-bundle.md §2, §7.1 decision proof#148). Absent for every
525
+ // bundle without it, additive and backward-compatible.
526
+ notes?: string[];
517
527
  }
518
528
 
519
529
  // ---- Attested-evidence-export wire + result types (evidence-export.md) -------
@@ -669,6 +679,43 @@ export interface VerifyEvidenceOptions {
669
679
  fetchImpl?: typeof fetch;
670
680
  }
671
681
 
682
+ /**
683
+ * A surface MUST NOT present a wrapper's `co_signatures` as evidence
684
+ * (evidence-bundle.md §2, decided by Phil 2026-09-24 on proof#148): nothing
685
+ * in either kernel verifies the field, and it sits outside the wrapper's
686
+ * signed scope (`bundleForSig` below), so anyone holding a pack's bytes can
687
+ * append it with no key at all. This is the one sentence both kernels attach
688
+ * as a note, never a verdict, never an exit code.
689
+ */
690
+ export const CO_SIGNATURES_NOTE =
691
+ "unverified co-signatures are present in this pack's wrapper and are not part of the evidence";
692
+
693
+ /**
694
+ * True iff the wrapper's reserved `co_signatures` field carries at least one
695
+ * entry.
696
+ *
697
+ * Absence and an empty array read the same way here: the field asserts
698
+ * nothing about a second signer until it holds one, and evidence-bundle.md's
699
+ * own carve-out language for the analogous envelope field treats "absent"
700
+ * and "an empty array" as one thing (envelope-spec.md §7.1). A non-array
701
+ * value (malformed shape, but the field is reserved, not yet specified)
702
+ * still counts as present: it is not the well-formed absence either rule
703
+ * describes.
704
+ */
705
+ export function coSignaturesPresent(b: unknown): boolean {
706
+ if (b === null || typeof b !== "object" || Array.isArray(b)) return false;
707
+ const value = (b as Record<string, unknown>).co_signatures;
708
+ if (!("co_signatures" in (b as Record<string, unknown>)) || value === null || value === undefined) {
709
+ return false;
710
+ }
711
+ if (Array.isArray(value)) return value.length > 0;
712
+ return true;
713
+ }
714
+
715
+ function coSignaturesNote(bundle: unknown): string | undefined {
716
+ return coSignaturesPresent(bundle) ? CO_SIGNATURES_NOTE : undefined;
717
+ }
718
+
672
719
  function isEvidenceMajorAccepted(specVersion: unknown): boolean {
673
720
  if (typeof specVersion !== "string" || specVersion === "") return false;
674
721
  return ACCEPTED_EVIDENCE_MAJORS.some((m) => {
@@ -690,6 +737,10 @@ function isEvidenceMajorAccepted(specVersion: unknown): boolean {
690
737
  * (`evidence-bundle.md` 5.5.1), so reporting `false` here let a holder who
691
738
  * broke its own body shape read as a forger.
692
739
  */
740
+ /** evidence-bundle §2 (A31). Byte-identical to the Python kernel's. */
741
+ export const GENERATED_AT_MALFORMED =
742
+ "malformed generated_at: not an RFC 3339 UTC date-time with Z naming a real date and time (evidence-bundle §2)";
743
+
693
744
  function malformed(error: string, specVersionOk = false, wrapperOk = false): EvidenceBundleResult {
694
745
  return {
695
746
  status: "malformed",
@@ -710,7 +761,9 @@ function malformed(error: string, specVersionOk = false, wrapperOk = false): Evi
710
761
  * the Python kernel writes too. Anything outside this set comes from the host:
711
762
  * a JSON parser, a codec, a crypto library. See `thrownDetail`.
712
763
  */
713
- const KERNEL_WORDING = ["hexToBytes: "];
764
+ // `duplicate key ` is `DuplicateKeyError` in `verifier.ts`, which
765
+ // `DuplicateKey` in the Python twin's `canonicalize.py` words identically.
766
+ const KERNEL_WORDING = ["hexToBytes: ", "duplicate key "];
714
767
 
715
768
  /**
716
769
  * The detail a finding may append about a caught exception.
@@ -728,6 +781,27 @@ const KERNEL_WORDING = ["hexToBytes: "];
728
781
  * is the host's parse offset, which no reader can act on and which was never
729
782
  * the same in the two kernels.
730
783
  */
784
+ /**
785
+ * The kernel's `duplicate key "<name>"` when a record repeats a member name,
786
+ * else `undefined` (proof#144; `_record_duplicate_key` in the Python twin).
787
+ *
788
+ * ONLY A DUPLICATE, and not every unreadable record. Every reader below this
789
+ * one that parses a record treats an unreadable one as absent, so the content
790
+ * binding reads undetermined and the pack said "1 record discloses no bytes"
791
+ * about a record whose bytes it had read. A record that repeats a name is
792
+ * a defect in the artifact, and a defect this kernel can see fails, the rule
793
+ * `event_bytes` that are not hex already follow.
794
+ */
795
+ function recordDuplicateKey(eventBytes: Uint8Array | undefined): string | undefined {
796
+ if (eventBytes === undefined) return undefined;
797
+ try {
798
+ jcsParse(eventBytes);
799
+ } catch (e) {
800
+ if (e instanceof DuplicateKeyError) return e.message;
801
+ }
802
+ return undefined;
803
+ }
804
+
731
805
  function thrownDetail(e: unknown): string {
732
806
  const text = e instanceof Error ? e.message : String(e);
733
807
  return KERNEL_WORDING.some((w) => text.startsWith(w)) ? `: ${text}` : "";
@@ -804,7 +878,17 @@ export async function verifyEvidenceBundle(
804
878
  bundle: unknown,
805
879
  options: VerifyEvidenceOptions = {},
806
880
  ): Promise<EvidenceBundleResult> {
807
- return verifyBundle(bundle, options, false);
881
+ // The ONE place a present `co_signatures` becomes a note, so every caller
882
+ // (including `case.ts`, which calls this function directly per pack) gets
883
+ // it, however the wrapper otherwise verifies. Never touches `status` or
884
+ // the exit code: a bundle that would otherwise verify still verifies, note
885
+ // or not (proof#148).
886
+ const result = await verifyBundle(bundle, options, false);
887
+ const note = coSignaturesNote(bundle);
888
+ if (note !== undefined) {
889
+ result.notes = [...(result.notes ?? []), note];
890
+ }
891
+ return result;
808
892
  }
809
893
 
810
894
  // `exportSource`: read the bundle as an export's source (evidence-export.md
@@ -830,12 +914,16 @@ async function verifyBundle(
830
914
  const b = bundle as EvidenceBundle;
831
915
  const specVersionOk = isEvidenceMajorAccepted(b.spec_version);
832
916
  if (!specVersionOk) {
833
- return malformed(`unsupported evidence spec_version: ${JSON.stringify(b.spec_version)}`, false);
917
+ // `?? null`: an ABSENT field is spelled `null`, as the Python twin's
918
+ // `_json_str` spells it. `JSON.stringify(undefined)` is `undefined`, and
919
+ // the two kernels printed two different sentences for one bundle until
920
+ // `verify --json` compared them byte for byte (0.13.0).
921
+ return malformed(`unsupported evidence spec_version: ${JSON.stringify(b.spec_version ?? null)}`, false);
834
922
  }
835
923
  if (typeof b.signature_alg === "string" && b.signature_alg !== "ed25519") {
836
924
  // The reference verifier implements ed25519 only (evidence-bundle §2 +
837
925
  // decision #2); other algs are a deliberate future addition.
838
- return malformed(`unsupported signature_alg: ${JSON.stringify(b.signature_alg)}`, true);
926
+ return malformed(`unsupported signature_alg: ${JSON.stringify(b.signature_alg ?? null)}`, true);
839
927
  }
840
928
  if (typeof b.public_key !== "string" || typeof b.signature !== "string") {
841
929
  return malformed("evidence bundle is missing public_key/signature", true);
@@ -885,7 +973,7 @@ async function verifyBundle(
885
973
  const { signature: _sig, co_signatures: _co, ...bundleForSig } = b as EvidenceBundle & {
886
974
  co_signatures?: unknown;
887
975
  };
888
- signatureOk = await ed25519Verify(b.signature, utf8(jcs(bundleForSig)), b.public_key);
976
+ signatureOk = await ed25519Verify(utf8(jcs(bundleForSig)), b.signature, b.public_key);
889
977
  if (!signatureOk) errors.push("wrapper Ed25519 signature verification failed");
890
978
  } catch (e) {
891
979
  errors.push(`wrapper signature verification error${thrownDetail(e)}`);
@@ -911,6 +999,19 @@ async function verifyBundle(
911
999
  };
912
1000
  }
913
1001
 
1002
+ // --- The wrapper's own time (evidence-bundle §2, A31) ---------------------
1003
+ // `generated_at` is copied into a recomputed verdict's `as_of` (the export
1004
+ // reading) and printed beside every verdict, so a present value that is not
1005
+ // the RFC 3339 form `signed_at` already pins is a wrapper this kernel cannot
1006
+ // render, whatever its signature. It is read only once the wrapper holds: a
1007
+ // broken wrapper is `failed` above, as it was. Absence is unchanged.
1008
+ if (
1009
+ Object.prototype.hasOwnProperty.call(b, "generated_at") &&
1010
+ !isRfc3339UtcInstant((b as { generated_at?: unknown }).generated_at)
1011
+ ) {
1012
+ return malformed(GENERATED_AT_MALFORMED, specVersionOk, true);
1013
+ }
1014
+
914
1015
  // --- Body dispatch ---------------------------------------------------------
915
1016
  const bodyType = typeof b.body_type === "string" ? b.body_type : null;
916
1017
 
@@ -1135,7 +1236,7 @@ function parseAnchorRef(
1135
1236
  }
1136
1237
  const o = raw as Record<string, unknown>;
1137
1238
  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)}`);
1239
+ errors.push(`anchor_ref.kind is missing or malformed: ${JSON.stringify(o.kind ?? null)}`);
1139
1240
  return { ref: null, malformed: true };
1140
1241
  }
1141
1242
  if (typeof o.id !== "string" || o.id === "") {
@@ -1384,6 +1485,11 @@ async function verifyAnchorTrace(
1384
1485
  eventBytesMalformed = true;
1385
1486
  evErrors.push(`event event_bytes is not hex${thrownDetail(e)}`);
1386
1487
  }
1488
+ const duplicate = recordDuplicateKey(eventBytes);
1489
+ if (duplicate !== undefined) {
1490
+ eventBytesMalformed = true;
1491
+ evErrors.push(`event record is not JSON: ${duplicate}`);
1492
+ }
1387
1493
  try {
1388
1494
  const res = await verifyEnvelope(
1389
1495
  ev.envelope,
@@ -1711,21 +1817,61 @@ async function verifyDisclosedContent(
1711
1817
  const evErrors: string[] = [];
1712
1818
  let contentOk: boolean | null = null;
1713
1819
 
1820
+ // WHICH hash in the record is decided by the record's STRUCTURE
1821
+ // (evidence-bundle §5.1 v1.19, A20). A record `envelope-spec.md` §4.1
1822
+ // governs (`isHandoffNamespace`) commits to its message through
1823
+ // `event.message_hash`, SHA-256 of `JCS(message object)`, and has no
1824
+ // `content_hash`, so until A20 its disclosure bound to nothing and an honest
1825
+ // hand-off pack could never grade `verified`. For such a record the committed
1826
+ // hash is `event.message_hash` and nothing else: not a `content_hash` it also
1827
+ // carries, and not the envelope's. It binds only when the record is a
1828
+ // CONFORMANT hand-off record under the key that signed it
1829
+ // (`isConformantHandoffRecord`) and the value is 64 characters of lowercase
1830
+ // hex. Anything short of that commits nothing this reader will bind to, so
1831
+ // the reading is undetermined and never true: a record dressed as a hand-off
1832
+ // record gains no binding from the dress, and a record outside §4.1 keeps its
1833
+ // `content_hash` exactly as before, whatever `message_hash` field it writes.
1834
+ // The Python twin is `evidence._evaluate_content`.
1714
1835
  const committed: string[] = [];
1715
1836
  let recordContentHash: string | undefined;
1837
+ let governed = false;
1838
+ let handoffHash: string | undefined;
1716
1839
  if (eventBytes !== undefined) {
1717
1840
  try {
1718
- const record = jcsParse(eventBytes) as {
1841
+ const parsed = jcsParse(eventBytes);
1842
+ const record = parsed as {
1719
1843
  event?: { content_hash?: unknown };
1720
- };
1721
- const ch = record.event?.content_hash;
1844
+ } | null;
1845
+ const ch = record?.event?.content_hash;
1722
1846
  if (typeof ch === "string" && ch) recordContentHash = ch.toLowerCase();
1847
+ if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
1848
+ const envelope = ev.envelope;
1849
+ const held: HeldRecord = {
1850
+ publicKey:
1851
+ envelope !== null && typeof envelope === "object" && !Array.isArray(envelope)
1852
+ ? ((envelope as { public_key?: unknown }).public_key as string)
1853
+ : (undefined as unknown as string),
1854
+ record: parsed as HeldRecord["record"],
1855
+ ref: "",
1856
+ };
1857
+ if (isHandoffNamespace(held)) {
1858
+ governed = true;
1859
+ const event = (parsed as { event?: unknown }).event;
1860
+ const mh =
1861
+ event !== null && typeof event === "object" && !Array.isArray(event)
1862
+ ? (event as { message_hash?: unknown }).message_hash
1863
+ : undefined;
1864
+ if (isConformantHandoffRecord(held) && isLowerHex(mh, 64)) handoffHash = mh;
1865
+ }
1866
+ }
1723
1867
  } catch {
1724
1868
  // record not JSON — the payload binding already reflects that; content
1725
1869
  // stays undetermined rather than throwing.
1726
1870
  }
1727
1871
  }
1728
- if (recordContentHash !== undefined) {
1872
+ if (governed) {
1873
+ if (handoffHash !== undefined) committed.push(handoffHash);
1874
+ } else if (recordContentHash !== undefined) {
1729
1875
  committed.push(recordContentHash);
1730
1876
  } else if (ev.envelope && typeof ev.envelope === "object") {
1731
1877
  // Promoted fallback. Guarded: contentHashes() dereferences env.payload,
@@ -1749,16 +1895,21 @@ async function verifyDisclosedContent(
1749
1895
  contentOk = null;
1750
1896
  if (disclosed !== undefined && committed.length === 0) {
1751
1897
  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",
1898
+ governed
1899
+ ? "disclosed content present but this hand-off record commits no " +
1900
+ "message_hash a reader can bind to (not a conformant hand-off " +
1901
+ "record, or message_hash is not 64 lowercase hex) -- undetermined"
1902
+ : "disclosed content present but no committed content_hash to bind to " +
1903
+ "(record withheld or record has no event.content_hash) -- undetermined",
1754
1904
  );
1755
1905
  }
1756
1906
  } else {
1757
1907
  const got = await sha256Hex(disclosed);
1758
1908
  contentOk = committed.includes(got);
1759
1909
  if (!contentOk) {
1910
+ const fieldName = governed ? "message_hash" : "content_hash";
1760
1911
  evErrors.push(
1761
- "disclosed content does not match the committed content_hash: " +
1912
+ `disclosed content does not match the committed ${fieldName}: ` +
1762
1913
  `sha256(disclosed)=${got} committed=${committed.join("|")}`,
1763
1914
  );
1764
1915
  }
@@ -1882,7 +2033,7 @@ async function verifyStampEntry(
1882
2033
  // reader's dispatch key and it said v1; honouring the other spelling is
1883
2034
  // how a reader half-parses a shape it does not know.
1884
2035
  errors.push(
1885
- `unsupported stamp_version ${JSON.stringify(body.stamp_version)} in a ` +
2036
+ `unsupported stamp_version ${JSON.stringify(body.stamp_version ?? null)} in a ` +
1886
2037
  `${STAMP_TRACE_BODY_TYPE} body (expected ${STAMP_BODY_VERSION})`,
1887
2038
  );
1888
2039
  return { result: base, witnessedHex };
@@ -1894,6 +2045,10 @@ async function verifyStampEntry(
1894
2045
  if (typeof body[f] !== "string" || body[f] === "") {
1895
2046
  errors.push(`stamp body ${f} is missing or not a non-empty string`);
1896
2047
  base.bodyOk = false;
2048
+ } else if (f !== "org_ref" && !isRfc3339UtcInstant(body[f])) {
2049
+ // §5.2 (A31): the promise window is the form `signed_at` pins.
2050
+ errors.push(`stamp body ${f} is not an RFC 3339 UTC date-time with Z naming a real date and time`);
2051
+ base.bodyOk = false;
1897
2052
  }
1898
2053
  }
1899
2054
  base.intervalStart = typeof body.interval_start === "string" ? body.interval_start : null;
@@ -2204,6 +2359,11 @@ async function verifyStampEvidence(
2204
2359
  eventBytesMalformed = true;
2205
2360
  evErrors.push(`event event_bytes is not hex${thrownDetail(e)}`);
2206
2361
  }
2362
+ const duplicate = recordDuplicateKey(eventBytes);
2363
+ if (duplicate !== undefined) {
2364
+ eventBytesMalformed = true;
2365
+ evErrors.push(`event record is not JSON: ${duplicate}`);
2366
+ }
2207
2367
  try {
2208
2368
  const res = await verifyEnvelope(
2209
2369
  ev.envelope,
@@ -2429,8 +2589,8 @@ async function verifyStampEvidence(
2429
2589
  * synthesis exists to drive `gatewayUnavailable`, which is what downgrades that
2430
2590
  * run to `partial`. Here §5.2.4 already downgrades an unwitnessed stamp to
2431
2591
  * `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
2592
+ * nothing to the status, which is `partial` either way, and would add an
2593
+ * unreachable-gateway note for a network event that never happened, based on
2434
2594
  * nothing but whether the caller happened to pass a gateway list.
2435
2595
  */
2436
2596
  async function refetchStamps(
@@ -2840,8 +3000,11 @@ async function verifyExportBody(
2840
3000
  // step-5 erratum flagged in the slice report.
2841
3001
  let verdictAgreementOk = false;
2842
3002
  try {
2843
- const cachedProjection = jcs(projectVerdictForAgreement(body.kernel_verdict));
2844
- const freshProjection = jcs(projectVerdictForAgreement(verdict));
3003
+ const cached = projectVerdictForAgreement(body.kernel_verdict);
3004
+ const fresh = projectVerdictForAgreement(verdict);
3005
+ toleratePreA20Content(cached, fresh, sourceEventsGoverned(inline));
3006
+ const cachedProjection = jcs(cached);
3007
+ const freshProjection = jcs(fresh);
2845
3008
  verdictAgreementOk = cachedProjection === freshProjection;
2846
3009
  if (!verdictAgreementOk) {
2847
3010
  errors.push(
@@ -2874,8 +3037,8 @@ async function verifyExportBody(
2874
3037
  ) {
2875
3038
  status = "failed";
2876
3039
  } 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.
3040
+ // Undetermined source (a withheld record, or an unreachable gateway):
3041
+ // exit 3 either way since 0.13.0. Never a failure.
2879
3042
  status = "partial";
2880
3043
  } else {
2881
3044
  status = "verified";
@@ -3030,6 +3193,10 @@ function payloadFieldError(payload: Record<string, unknown>): string | null {
3030
3193
  want = "1, 2 or 3";
3031
3194
  }
3032
3195
  if (!ok) return `attestation payload field ${JSON.stringify(name)} is missing or not ${want}`;
3196
+ if (name === "attested_at" && !isRfc3339UtcInstant(v)) {
3197
+ // evidence-export §3.1 (v3.4): the form `signed_at` pins.
3198
+ return 'attestation payload field "attested_at" is not an RFC 3339 UTC date-time with Z naming a real date and time';
3199
+ }
3033
3200
  }
3034
3201
  return null;
3035
3202
  }
@@ -3064,7 +3231,7 @@ async function verifyAttestation(
3064
3231
  if (rec.signature_alg !== "rsa-pss-sha256") {
3065
3232
  return {
3066
3233
  result,
3067
- malformed: `unsupported attestation signature_alg: ${JSON.stringify(rec.signature_alg)}`,
3234
+ malformed: `unsupported attestation signature_alg: ${JSON.stringify(rec.signature_alg ?? null)}`,
3068
3235
  };
3069
3236
  }
3070
3237
  const pub = rec.public_key as { kty?: unknown; n?: unknown; e?: unknown } | null;
@@ -3266,7 +3433,11 @@ function eventVerdictStatus(e: EventResult): string {
3266
3433
  // nullish normalization) — a structural difference surfaces as disagreement,
3267
3434
  // which is the correct tamper signal. Works on both the cached snake_case JSON
3268
3435
  // and the freshly built VerdictObject.
3269
- function projectVerdictForAgreement(v: unknown): unknown {
3436
+ function projectVerdictForAgreement(v: unknown): {
3437
+ schema_version: string | null;
3438
+ events: { content_ok: boolean | null }[];
3439
+ checkpoints: unknown[];
3440
+ } {
3270
3441
  const o = (v ?? {}) as VerdictObject;
3271
3442
  const events = Array.isArray(o.events) ? o.events : [];
3272
3443
  const checkpoints = Array.isArray(o.checkpoints) ? o.checkpoints : [];
@@ -3304,6 +3475,69 @@ function projectVerdictForAgreement(v: unknown): unknown {
3304
3475
  };
3305
3476
  }
3306
3477
 
3478
+ // Per event of an export's inline `sanning.anchor.trace/v3` source, in order:
3479
+ // whether `envelope-spec.md` §4.1 governs its record. Decoded the way the
3480
+ // content binding decodes it, so an event whose record is withheld, not hex or
3481
+ // not JCS is not governed here either. The Python twin is
3482
+ // `evidence._source_events_governed`.
3483
+ function sourceEventsGoverned(source: unknown): boolean[] {
3484
+ if (source === null || typeof source !== "object" || Array.isArray(source)) return [];
3485
+ const s = source as { body_type?: unknown; body?: unknown };
3486
+ if (s.body_type !== ANCHOR_TRACE_BODY_TYPE) return [];
3487
+ const body = s.body as { events?: unknown } | null | undefined;
3488
+ const events =
3489
+ body !== null && typeof body === "object" && !Array.isArray(body) ? body.events : undefined;
3490
+ if (!Array.isArray(events)) return [];
3491
+ return events.map((raw: unknown) => {
3492
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return false;
3493
+ const ev = raw as { event_bytes?: unknown; envelope?: unknown };
3494
+ if (typeof ev.event_bytes !== "string") return false;
3495
+ try {
3496
+ const parsed = jcsParse(hexToBytes(ev.event_bytes));
3497
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return false;
3498
+ const envelope = ev.envelope;
3499
+ const held: HeldRecord = {
3500
+ publicKey:
3501
+ envelope !== null && typeof envelope === "object" && !Array.isArray(envelope)
3502
+ ? ((envelope as { public_key?: unknown }).public_key as string)
3503
+ : (undefined as unknown as string),
3504
+ record: parsed as HeldRecord["record"],
3505
+ ref: "",
3506
+ };
3507
+ return isHandoffNamespace(held);
3508
+ } catch {
3509
+ return false;
3510
+ }
3511
+ });
3512
+ }
3513
+
3514
+ // The one difference §5 step 5 does not read as a disagreement
3515
+ // (evidence-export.md, evidence-bundle.md v1.19, A20): a cached `content_ok` of
3516
+ // `null` against a recomputed `true`, on an event whose record §4.1 governs. A
3517
+ // kernel before A20 could not bind a hand-off record's disclosed message, so an
3518
+ // export it composed claims less than the recompute proves, and reading that
3519
+ // as tamper failed an honest export. Only that direction: a cached `true`
3520
+ // against a recomputed `false` or `null`, `null` against `false`, and any
3521
+ // difference on a record outside §4.1 still disagree. Edits `fresh` in place
3522
+ // to match `cached` on those events. The Python twin is
3523
+ // `evidence._tolerate_pre_a20_content`.
3524
+ function toleratePreA20Content(
3525
+ cached: { events: { content_ok: boolean | null }[] },
3526
+ fresh: { events: { content_ok: boolean | null }[] },
3527
+ governed: boolean[],
3528
+ ): void {
3529
+ const n = Math.min(cached.events.length, fresh.events.length);
3530
+ for (let i = 0; i < n; i++) {
3531
+ if (
3532
+ governed[i] === true &&
3533
+ cached.events[i]!.content_ok === null &&
3534
+ fresh.events[i]!.content_ok === true
3535
+ ) {
3536
+ fresh.events[i]!.content_ok = null;
3537
+ }
3538
+ }
3539
+ }
3540
+
3307
3541
  function summarizeExport(
3308
3542
  status: EvidenceStatus,
3309
3543
  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
+ }