@mikeargento/bitgraph-audit 0.8.0 → 0.10.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 (51) hide show
  1. package/README.md +8 -2
  2. package/dist/audit.d.ts +9 -6
  3. package/dist/audit.d.ts.map +1 -1
  4. package/dist/audit.js +34 -8
  5. package/dist/audit.js.map +1 -1
  6. package/dist/ceilings.d.ts.map +1 -1
  7. package/dist/ceilings.js +1 -0
  8. package/dist/ceilings.js.map +1 -1
  9. package/dist/cli.js +109 -21
  10. package/dist/cli.js.map +1 -1
  11. package/dist/exports.d.ts +39 -0
  12. package/dist/exports.d.ts.map +1 -0
  13. package/dist/exports.js +374 -0
  14. package/dist/exports.js.map +1 -0
  15. package/dist/floors.d.ts +23 -0
  16. package/dist/floors.d.ts.map +1 -0
  17. package/dist/floors.js +203 -0
  18. package/dist/floors.js.map +1 -0
  19. package/dist/index.d.ts +6 -1
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +5 -0
  22. package/dist/index.js.map +1 -1
  23. package/dist/ingest.d.ts +2 -0
  24. package/dist/ingest.d.ts.map +1 -1
  25. package/dist/ingest.js +193 -19
  26. package/dist/ingest.js.map +1 -1
  27. package/dist/report-json.d.ts.map +1 -1
  28. package/dist/report-json.js +30 -0
  29. package/dist/report-json.js.map +1 -1
  30. package/dist/report-md.d.ts.map +1 -1
  31. package/dist/report-md.js +315 -25
  32. package/dist/report-md.js.map +1 -1
  33. package/dist/temporal.d.ts +12 -0
  34. package/dist/temporal.d.ts.map +1 -1
  35. package/dist/temporal.js +245 -41
  36. package/dist/temporal.js.map +1 -1
  37. package/dist/types.d.ts +348 -7
  38. package/dist/types.d.ts.map +1 -1
  39. package/package.json +3 -2
  40. package/src/__tests__/base-floor.test.ts +405 -0
  41. package/src/audit.ts +36 -8
  42. package/src/ceilings.ts +1 -0
  43. package/src/cli.ts +117 -22
  44. package/src/exports.ts +482 -0
  45. package/src/floors.ts +242 -0
  46. package/src/index.ts +20 -0
  47. package/src/ingest.ts +207 -18
  48. package/src/report-json.ts +30 -0
  49. package/src/report-md.ts +353 -29
  50. package/src/temporal.ts +279 -48
  51. package/src/types.ts +341 -6
package/src/ingest.ts CHANGED
@@ -14,9 +14,20 @@
14
14
  * with the stable code "unsupported-version", counted, listed, and excluded
15
15
  * from verification, chain reconstruction, and anomaly analysis.
16
16
  *
17
+ * An export (bitgraph-export/1, found by its format field) is evidence,
18
+ * never an artifact: it is listed for the export stage (exports.ts), and the
19
+ * proof it carries is recorded as an observed proof from the export's path,
20
+ * exactly like a proof file. A file that declares another bitgraph-export
21
+ * format, or lacks an export's structure, is listed as rejected.
22
+ *
23
+ * A floor header file (bitgraph-floor-header/1, found by its version field)
24
+ * is evidence too: the header of the Base block a proof signs as its floor
25
+ * (commit.slotFloor, enclave v10), listed for the temporal stage.
26
+ *
17
27
  * Memory: archives are never unpacked to disk and never loaded whole.
18
28
  * Every entry is hashed incrementally as it streams. Only small JSON
19
- * candidates (at most MAX_CANDIDATE_JSON_BYTES) are buffered for parsing;
29
+ * candidates (at most MAX_CANDIDATE_JSON_BYTES) are buffered for parsing,
30
+ * and entries that open an export object (at most maxExportJsonBytes);
20
31
  * artifact bytes are hashed and dropped. What ingest retains scales with
21
32
  * the number of entries and the total size of the proof JSONs, not with
22
33
  * artifact payload sizes.
@@ -37,18 +48,20 @@ import { open, readdir, readFile, stat } from "node:fs/promises";
37
48
  import { join } from "node:path";
38
49
  import { createGunzip } from "node:zlib";
39
50
  import { sha256 } from "@noble/hashes/sha256";
40
- import { computeProofHash, computeChainHash } from "@mikeargento/bitgraph-verify";
51
+ import { computeProofHash, computeChainHash, EXPORT_FORMAT, parseExport } from "@mikeargento/bitgraph-verify";
41
52
  import type { BitGraphProof } from "@mikeargento/bitgraph-verify";
42
53
  import { readTarEntries } from "./tar.js";
43
54
  import { combineEntryDigests } from "./contents-hash.js";
44
55
  import type {
45
56
  AnchorWitnessFile,
46
57
  CeilingFile,
58
+ FloorHeaderFile,
47
59
  ArtifactRecord,
48
60
  AuditFinding,
49
61
  BundleManifest,
50
62
  ContainerKind,
51
63
  EmbeddedProofHashStatus,
64
+ ExportFile,
52
65
  IngestCounts,
53
66
  IngestLimits,
54
67
  IngestResult,
@@ -67,6 +80,20 @@ import type {
67
80
  */
68
81
  const MAX_CANDIDATE_JSON_BYTES = 8 * 1024 * 1024;
69
82
 
83
+ /**
84
+ * An export's JSON may run past MAX_CANDIDATE_JSON_BYTES: the owner's export
85
+ * of a large tree lists every leaf (65 bytes each, base64) and a name per
86
+ * leaf, up to 1,000,000 leaves. An entry whose opening bytes open an export
87
+ * object ({"format": "bitgraph-export/...) is buffered up to this cap
88
+ * instead (IngestLimits.maxExportJsonBytes); a larger one is reported as an
89
+ * export too large to check, never silently taken for an ordinary file.
90
+ */
91
+ const MAX_EXPORT_JSON_BYTES = 192 * 1024 * 1024;
92
+ /** How many opening bytes decide whether an entry opens an export object. */
93
+ const EXPORT_SNIFF_BYTES = 256;
94
+ /** Producers write format first (buildExport); whitespace and one BOM are tolerated. */
95
+ const EXPORT_OPENING = /^\s*\{\s*"format"\s*:\s*"(bitgraph-export\/[^"\\]{0,64})"/;
96
+
70
97
  /**
71
98
  * Default resource caps for untrusted .tar / .tar.gz ingest. Chosen well
72
99
  * above any legitimate bundle (the 50k-proof benchmark bundle decompresses
@@ -77,6 +104,7 @@ export const DEFAULT_INGEST_LIMITS: IngestLimits = {
77
104
  maxTotalBytes: 2 * 1024 * 1024 * 1024, // 2 GiB decompressed
78
105
  maxEntryCount: 1_000_000,
79
106
  maxMetadataEntryBytes: 8 * 1024 * 1024, // 8 MiB per PAX/GNU metadata entry
107
+ maxExportJsonBytes: MAX_EXPORT_JSON_BYTES, // 192 MiB for one export's JSON, every container
80
108
  };
81
109
 
82
110
  const NUL = new Uint8Array([0]);
@@ -85,6 +113,10 @@ const BUNDLE_VERSION = "bitgraph-bundle/1";
85
113
  const WITNESS_VERSION = "bitgraph-anchor-witness/1";
86
114
  const CEILING_VERSION_TAG = "bitgraph-ceiling/1";
87
115
  const CEILING_STATUS_VERSION_TAG = "bitgraph-ceiling-status/1";
116
+ /** A Base floor block's header, beside the proof that signs it (the carrier/3 unpacker writes these). */
117
+ export const FLOOR_HEADER_VERSION = "bitgraph-floor-header/1";
118
+ /** Every export format starts with this; only EXPORT_FORMAT ("bitgraph-export/1") is checked. */
119
+ const EXPORT_FORMAT_PREFIX = "bitgraph-export/";
88
120
 
89
121
  // ---------------------------------------------------------------------------
90
122
  // Public API
@@ -111,6 +143,7 @@ export async function ingestBundle(
111
143
  const findings: AuditFinding[] = [];
112
144
  let skippedUnsafePaths = 0;
113
145
  let entriesScanned = 0;
146
+ const exportCap = limits.maxExportJsonBytes ?? MAX_EXPORT_JSON_BYTES;
114
147
 
115
148
  // ---------------------------------------------------------------------
116
149
  // Scan: stream every entry once, hashing incrementally.
@@ -122,7 +155,7 @@ export async function ingestBundle(
122
155
  for await (const file of walkDirectory(bundlePath)) {
123
156
  entriesScanned++;
124
157
  const stream = createReadStream(file.absPath);
125
- const hashed = await hashEntryStream(file.relPath, undefined, undefined, stream);
158
+ const hashed = await hashEntryStream(file.relPath, undefined, undefined, stream, exportCap);
126
159
  scanned.push(makeScannedEntry(file.relPath, hashed));
127
160
  }
128
161
  } else {
@@ -147,7 +180,7 @@ export async function ingestBundle(
147
180
  }
148
181
  const slash = normalized.path.indexOf("/");
149
182
  const strippedVariant = slash === -1 ? undefined : normalized.path.slice(slash + 1);
150
- const hashed = await hashEntryStream(normalized.path, strippedVariant, entry.size, entry.body);
183
+ const hashed = await hashEntryStream(normalized.path, strippedVariant, entry.size, entry.body, exportCap);
151
184
  scanned.push(makeScannedEntry(normalized.path, hashed));
152
185
  }
153
186
  } finally {
@@ -235,7 +268,8 @@ export async function ingestEntries(
235
268
  normalized.path,
236
269
  undefined,
237
270
  undefined,
238
- openAsChunks(entry.open())
271
+ openAsChunks(entry.open()),
272
+ MAX_EXPORT_JSON_BYTES
239
273
  );
240
274
  scanned.push(makeScannedEntry(normalized.path, hashed));
241
275
  sources.set(normalized.path, entry);
@@ -319,6 +353,7 @@ function finalizeIngest(params: FinalizeParams): IngestResult {
319
353
  byteLength: entry.byteLength,
320
354
  entryDigest,
321
355
  ...(entry.json !== undefined ? { json: entry.json } : {}),
356
+ ...(entry.exportTooLarge !== undefined ? { exportTooLarge: entry.exportTooLarge } : {}),
322
357
  });
323
358
  }
324
359
  const finalEntries = Array.from(finalByPath.values());
@@ -341,6 +376,8 @@ function finalizeIngest(params: FinalizeParams): IngestResult {
341
376
  const witnesses: AnchorWitnessFile[] = [];
342
377
  const ceilings: CeilingFile[] = [];
343
378
  const ceilingStatuses: CeilingFile[] = [];
379
+ const floorHeaders: FloorHeaderFile[] = [];
380
+ const exportFiles: ExportFile[] = [];
344
381
  const artifactsByHex = new Map<string, ArtifactRecord>();
345
382
  let manifest: ManifestReport | undefined;
346
383
  let proofFiles = 0;
@@ -353,6 +390,19 @@ function finalizeIngest(params: FinalizeParams): IngestResult {
353
390
  continue;
354
391
  }
355
392
 
393
+ // An export too large to parse is still an export: reported, never an
394
+ // artifact, and never silently checked as nothing.
395
+ if (entry.exportTooLarge !== undefined) {
396
+ exportFiles.push({ path: entry.path, fileSha256Hex: entry.sha256Hex, format: entry.exportTooLarge, status: "too-large", json: {} });
397
+ findings.push({
398
+ code: "export-too-large",
399
+ path: entry.path,
400
+ message: `the file opens an export (${entry.exportTooLarge}) but is ${entry.byteLength} bytes, past the audit's cap for one export's JSON; not checked`,
401
+ details: { byteLength: entry.byteLength },
402
+ });
403
+ continue;
404
+ }
405
+
356
406
  // A Frame (profile bitgraph-fuse/1) is the shipped carrier of a fused
357
407
  // recording: { type: "bitgraph-fuse/1", manifest, fusePayload?, proof }.
358
408
  // The nested proof is the member; the wrapper is never an artifact.
@@ -386,6 +436,45 @@ function finalizeIngest(params: FinalizeParams): IngestResult {
386
436
  continue;
387
437
  }
388
438
 
439
+ // An export (bitgraph-export/1) is one JSON file that, with the file it
440
+ // covers, checks a tree/1 BitGraph: { format, spec, proof, tree, floor,
441
+ // ceiling, settlement }. Found by its format field, never by name. It is
442
+ // evidence, never an artifact; the proof it carries joins the proof
443
+ // analysis like any other (counters, chain links, partitions, attestation).
444
+ const exportFormat = exportFormatOf(parsed);
445
+ if (exportFormat !== undefined) {
446
+ const record = classifyExport(entry, parsed as Record<string, unknown>, exportFormat, findings);
447
+ exportFiles.push(record);
448
+ const carried = record.status === "ok" ? (parsed as Record<string, unknown>)["proof"] : undefined;
449
+ if (carried !== undefined && isProofShaped(carried)) {
450
+ const inner = carried as Record<string, unknown>;
451
+ const version = inner["version"] as string;
452
+ if (version === "bitgraph/1") {
453
+ proofFiles++;
454
+ const outcome = recordMemberProof(entry, inner, proofsByHash, proofs, findings);
455
+ if (outcome === "exact-duplicate") exactDuplicates++;
456
+ if (outcome === "semantic-duplicate") semanticDuplicates++;
457
+ record.proofHash = computeProofHash(inner);
458
+ } else {
459
+ // The same version policy as any proof-shaped input; the export
460
+ // itself is still checked (its proof claim fails).
461
+ unsupportedVersions.push({
462
+ code: "unsupported-version",
463
+ path: entry.path,
464
+ version,
465
+ fileSha256Hex: entry.sha256Hex,
466
+ });
467
+ findings.push({
468
+ code: "unsupported-version",
469
+ path: entry.path,
470
+ message: `the proof inside this export is rejected: version "${version}" is not "bitgraph/1"`,
471
+ details: { version },
472
+ });
473
+ }
474
+ }
475
+ continue;
476
+ }
477
+
389
478
  if (
390
479
  parsed !== undefined &&
391
480
  (parsed as Record<string, unknown>)["version"] === WITNESS_VERSION
@@ -400,6 +489,11 @@ function finalizeIngest(params: FinalizeParams): IngestResult {
400
489
 
401
490
  // A ceiling in time travels beside its proof: evidence, never an artifact.
402
491
  const pv = parsed !== undefined ? (parsed as Record<string, unknown>)["version"] : undefined;
492
+ // So does a Base floor block's header: evidence for the temporal stage.
493
+ if (pv === FLOOR_HEADER_VERSION) {
494
+ floorHeaders.push({ path: entry.path, fileSha256Hex: entry.sha256Hex, json: parsed as Record<string, unknown> });
495
+ continue;
496
+ }
403
497
  if (pv === CEILING_VERSION_TAG || pv === CEILING_STATUS_VERSION_TAG) {
404
498
  (pv === CEILING_VERSION_TAG ? ceilings : ceilingStatuses).push({
405
499
  path: entry.path,
@@ -436,7 +530,9 @@ function finalizeIngest(params: FinalizeParams): IngestResult {
436
530
  unsupportedVersion: unsupportedVersions.length,
437
531
  artifacts: artifacts.length,
438
532
  witnesses: witnesses.length,
533
+ ...(floorHeaders.length > 0 ? { floorHeaders: floorHeaders.length } : {}),
439
534
  ceilings: ceilings.length + ceilingStatuses.length,
535
+ exports: exportFiles.length,
440
536
  skippedUnsafePaths,
441
537
  };
442
538
 
@@ -449,8 +545,10 @@ function finalizeIngest(params: FinalizeParams): IngestResult {
449
545
  unsupportedVersions,
450
546
  artifacts,
451
547
  witnesses,
548
+ ...(floorHeaders.length > 0 ? { floorHeaders } : {}),
452
549
  ceilings,
453
550
  ceilingStatuses,
551
+ exports: exportFiles,
454
552
  ...(manifest !== undefined ? { manifest } : {}),
455
553
  computedContentsHashB64,
456
554
  findings,
@@ -668,6 +766,8 @@ interface ScannedEntry {
668
766
  entryDigestFull: Uint8Array;
669
767
  entryDigestStripped?: Uint8Array;
670
768
  json?: unknown;
769
+ /** The format an entry opening an export declared, when it ran past the export JSON cap. */
770
+ exportTooLarge?: string;
671
771
  }
672
772
 
673
773
  interface FinalEntry {
@@ -676,6 +776,7 @@ interface FinalEntry {
676
776
  byteLength: number;
677
777
  entryDigest: Uint8Array;
678
778
  json?: unknown;
779
+ exportTooLarge?: string;
679
780
  }
680
781
 
681
782
  interface HashedEntry {
@@ -684,6 +785,7 @@ interface HashedEntry {
684
785
  entryDigestFull: Uint8Array;
685
786
  entryDigestStripped?: Uint8Array;
686
787
  buffered?: Uint8Array;
788
+ exportTooLarge?: string;
687
789
  }
688
790
 
689
791
  /**
@@ -693,13 +795,14 @@ interface HashedEntry {
693
795
  * - for tar entries below a potentially stripped root, the same entry
694
796
  * digest under the stripped path variant,
695
797
  * while buffering the content only if it stays within the JSON candidacy
696
- * cap.
798
+ * cap, or, for an entry that opens an export object, within the export cap.
697
799
  */
698
800
  async function hashEntryStream(
699
801
  path: string,
700
802
  strippedPathVariant: string | undefined,
701
803
  declaredSize: number | undefined,
702
- chunks: AsyncIterable<Uint8Array>
804
+ chunks: AsyncIterable<Uint8Array>,
805
+ exportCap: number
703
806
  ): Promise<HashedEntry> {
704
807
  const encoder = new TextEncoder();
705
808
  const contentHasher = sha256.create();
@@ -712,36 +815,72 @@ async function hashEntryStream(
712
815
  strippedHasher.update(NUL);
713
816
  }
714
817
 
715
- let buffering = declaredSize === undefined || declaredSize <= MAX_CANDIDATE_JSON_BYTES;
818
+ // json: buffer within the ordinary cap (past it, keep going only if the
819
+ // entry opens an export). sniff: declared past the ordinary cap, so only an
820
+ // export is worth buffering; decided on the opening bytes. export: buffer
821
+ // within the export cap. off: hash only.
822
+ let mode: "json" | "sniff" | "export" | "off" =
823
+ declaredSize === undefined || declaredSize <= MAX_CANDIDATE_JSON_BYTES ? "json" : "sniff";
824
+ let exportTooLarge: string | undefined;
825
+ const head = new Uint8Array(EXPORT_SNIFF_BYTES);
826
+ let headLength = 0;
716
827
  const buffered: Uint8Array[] = [];
717
828
  let total = 0;
829
+ const stop = (): "off" => {
830
+ buffered.length = 0;
831
+ return "off";
832
+ };
833
+ const tooLarge = (): "off" => {
834
+ exportTooLarge = exportOpening(head.subarray(0, headLength)) ?? "bitgraph-export/";
835
+ return stop();
836
+ };
718
837
 
719
838
  for await (const chunk of chunks) {
720
839
  contentHasher.update(chunk);
721
840
  fullHasher.update(chunk);
722
841
  strippedHasher?.update(chunk);
723
842
  total += chunk.length;
724
- if (buffering) {
725
- if (total > MAX_CANDIDATE_JSON_BYTES) {
726
- buffered.length = 0;
727
- buffering = false;
728
- } else {
729
- // Copy: tar body chunks are views into the container's stream
730
- // buffers and are not stable after iteration continues.
731
- buffered.push(chunk.slice());
732
- }
843
+ if (headLength < head.length) {
844
+ const take = Math.min(head.length - headLength, chunk.length);
845
+ head.set(chunk.subarray(0, take), headLength);
846
+ headLength += take;
733
847
  }
848
+ if (mode === "off") continue;
849
+ if (mode === "sniff" && headLength === head.length) {
850
+ mode = exportOpening(head) === undefined ? stop() : declaredSize !== undefined && declaredSize > exportCap ? tooLarge() : "export";
851
+ if (mode === "off") continue;
852
+ }
853
+ // Copy: tar body chunks are views into the container's stream
854
+ // buffers and are not stable after iteration continues.
855
+ buffered.push(chunk.slice());
856
+ if (mode === "json" && total > MAX_CANDIDATE_JSON_BYTES) {
857
+ mode = exportOpening(head.subarray(0, headLength)) === undefined ? stop() : "export";
858
+ }
859
+ if (mode === "export" && total > exportCap) mode = tooLarge();
860
+ }
861
+ if (mode === "sniff") {
862
+ // Shorter than the sniff window although declared past the ordinary cap.
863
+ mode = exportOpening(head.subarray(0, headLength)) === undefined || total > exportCap ? stop() : "export";
734
864
  }
735
865
 
866
+ const keep = mode === "json" || mode === "export";
736
867
  return {
737
868
  sha256Hex: toHex(contentHasher.digest()),
738
869
  byteLength: total,
739
870
  entryDigestFull: fullHasher.digest(),
740
871
  ...(strippedHasher !== undefined ? { entryDigestStripped: strippedHasher.digest() } : {}),
741
- ...(buffering ? { buffered: concatBytes(buffered, total) } : {}),
872
+ ...(keep ? { buffered: concatBytes(buffered, total) } : {}),
873
+ ...(exportTooLarge !== undefined ? { exportTooLarge } : {}),
742
874
  };
743
875
  }
744
876
 
877
+ /** The export format an entry's opening bytes declare ({"format": "bitgraph-export/..."), or undefined. */
878
+ function exportOpening(head: Uint8Array): string | undefined {
879
+ const start = head.length >= 3 && head[0] === 0xef && head[1] === 0xbb && head[2] === 0xbf ? 3 : 0;
880
+ const match = EXPORT_OPENING.exec(Buffer.from(head.subarray(start)).toString("latin1"));
881
+ return match?.[1];
882
+ }
883
+
745
884
  function makeScannedEntry(rawPath: string, hashed: HashedEntry): ScannedEntry {
746
885
  const json = hashed.buffered === undefined ? undefined : tryParseJsonObject(hashed.buffered);
747
886
  return {
@@ -753,6 +892,7 @@ function makeScannedEntry(rawPath: string, hashed: HashedEntry): ScannedEntry {
753
892
  ? { entryDigestStripped: hashed.entryDigestStripped }
754
893
  : {}),
755
894
  ...(json !== undefined ? { json } : {}),
895
+ ...(hashed.exportTooLarge !== undefined ? { exportTooLarge: hashed.exportTooLarge } : {}),
756
896
  };
757
897
  }
758
898
 
@@ -873,6 +1013,55 @@ function isProofShaped(value: unknown): boolean {
873
1013
  );
874
1014
  }
875
1015
 
1016
+ // ---------------------------------------------------------------------------
1017
+ // Export classification (bitgraph-export/1)
1018
+ // ---------------------------------------------------------------------------
1019
+
1020
+ /** The format string of an export-shaped value ("bitgraph-export/..."), or undefined. */
1021
+ function exportFormatOf(value: unknown): string | undefined {
1022
+ if (!isPlainObject(value)) return undefined;
1023
+ const format = value["format"];
1024
+ return typeof format === "string" && format.startsWith(EXPORT_FORMAT_PREFIX) ? format : undefined;
1025
+ }
1026
+
1027
+ /**
1028
+ * Classify one export-shaped file. Structure is decided by bitgraph-verify's
1029
+ * parseExport, so the audit and the verifier agree on what an export is. A
1030
+ * file that declares an export format but is not one is recorded with a
1031
+ * finding and is never an artifact.
1032
+ */
1033
+ function classifyExport(
1034
+ entry: FinalEntry,
1035
+ parsed: Record<string, unknown>,
1036
+ format: string,
1037
+ findings: AuditFinding[]
1038
+ ): ExportFile {
1039
+ const base = { path: entry.path, fileSha256Hex: entry.sha256Hex, format, json: parsed };
1040
+ if (format !== EXPORT_FORMAT) {
1041
+ findings.push({
1042
+ code: "export-unsupported-format",
1043
+ path: entry.path,
1044
+ message: `export-shaped file not checked: format "${format}" is not "${EXPORT_FORMAT}"`,
1045
+ details: { format },
1046
+ });
1047
+ return { ...base, status: "unsupported-format" };
1048
+ }
1049
+ if (parseExport(parsed) === null) {
1050
+ const tree = parsed["tree"];
1051
+ const missing = !isPlainObject(parsed["proof"])
1052
+ ? "a proof object"
1053
+ : "tree.rootDocument (a string)";
1054
+ findings.push({
1055
+ code: "export-malformed",
1056
+ path: entry.path,
1057
+ message: `declares ${EXPORT_FORMAT} but lacks ${missing}; not checked`,
1058
+ details: { hasProof: isPlainObject(parsed["proof"]), hasTree: isPlainObject(tree) },
1059
+ });
1060
+ return { ...base, status: "malformed" };
1061
+ }
1062
+ return { ...base, status: "ok" };
1063
+ }
1064
+
876
1065
  type DuplicateKind = "new" | "exact-duplicate" | "semantic-duplicate";
877
1066
 
878
1067
  function recordMemberProof(
@@ -112,6 +112,7 @@ export function buildJsonReport(result: AuditResult): AuditJsonReport {
112
112
  ...(result.ceilings && (result.ceilings.checks.length > 0 || result.ceilings.statuses.length > 0)
113
113
  ? { ceilings: result.ceilings }
114
114
  : {}),
115
+ ...(result.exports && result.exports.checks.length > 0 ? { exports: result.exports } : {}),
115
116
  attestations: {
116
117
  records: result.attestations.records,
117
118
  counts: result.attestations.counts,
@@ -127,6 +128,8 @@ export function buildJsonReport(result: AuditResult): AuditJsonReport {
127
128
  segments: result.temporal.segments,
128
129
  verifiedAnchorProofHashes: result.temporal.verifiedAnchorProofHashes,
129
130
  unverifiedAnchorProofHashes: result.temporal.unverifiedAnchorProofHashes,
131
+ ...(result.temporal.signedFloors !== undefined ? { signedFloors: result.temporal.signedFloors } : {}),
132
+ ...(result.temporal.floorProblems !== undefined ? { floorProblems: result.temporal.floorProblems } : {}),
130
133
  },
131
134
  summary,
132
135
  };
@@ -286,11 +289,15 @@ function buildSummary(
286
289
  if (proof.measurement !== undefined) measurements.add(proof.measurement);
287
290
  }
288
291
 
292
+ const floors = result.temporal.signedFloors ?? [];
293
+ const floorProblems = result.temporal.floorProblems ?? [];
289
294
  const segmentCount = (status: TemporalSegmentStatus): number =>
290
295
  result.temporal.segments.filter((s) => s.status === status).length;
291
296
 
292
297
  const partitionsIntact = partitions.filter((p) => p.intact).length;
293
298
  const exit = computeExitFlags(result);
299
+ const exportChecks = result.exports?.checks ?? [];
300
+ const checkedExports = exportChecks.filter((e) => e.status === "checked");
294
301
 
295
302
  return {
296
303
  proofsObserved: result.ingest.counts.observed,
@@ -330,7 +337,30 @@ function buildSummary(
330
337
  segmentsLowerBounded: segmentCount("lower-bounded"),
331
338
  segmentsUpperBounded: segmentCount("upper-bounded"),
332
339
  segmentsUnanchored: segmentCount("ordered-but-unanchored"),
340
+ ...(floors.length > 0 || floorProblems.length > 0
341
+ ? {
342
+ baseFloors: {
343
+ signed: floors.length,
344
+ bounding: floors.filter((f) => f.bound === "not-before").length,
345
+ headersChecked: floors.filter((f) => f.header === "checked").length,
346
+ withheld: floors.filter((f) => f.bound === "withheld").length,
347
+ problems: floorProblems.length,
348
+ },
349
+ }
350
+ : {}),
333
351
  },
352
+ ...(exportChecks.length > 0
353
+ ? {
354
+ exports: {
355
+ files: exportChecks.length,
356
+ verdictTrue: checkedExports.filter((e) => e.verdict === "TRUE").length,
357
+ verdictFalse: checkedExports.filter((e) => e.verdict === "FALSE").length,
358
+ verdictUndetermined: checkedExports.filter((e) => e.verdict === "UNDETERMINED").length,
359
+ rejected: exportChecks.length - checkedExports.length,
360
+ coveredFilesChecked: checkedExports.reduce((n, e) => n + e.runs.filter((r) => r.file !== null).length, 0),
361
+ },
362
+ }
363
+ : {}),
334
364
  exit,
335
365
  };
336
366
  }