@mikeargento/bitgraph-audit 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/anomalies.ts CHANGED
@@ -34,6 +34,7 @@
34
34
 
35
35
  import type {
36
36
  AnomalyReport,
37
+ BoundaryEntryPoint,
37
38
  ChainAnomaly,
38
39
  ChainPartition,
39
40
  DivergenceParty,
@@ -72,6 +73,7 @@ export async function classifyAnomalies(
72
73
  ): Promise<AnomalyReport> {
73
74
  const anomalies: ChainAnomaly[] = [];
74
75
  const divergences: DivergenceRecord[] = [];
76
+ const boundaryEntryPoints: BoundaryEntryPoint[] = [];
75
77
 
76
78
  const byHash = new Map<string, ObservedProof>(ingest.proofs.map((p) => [p.proofHash, p]));
77
79
  // Predecessor links (prevB64, epochLink.prevProofHashB64) reference the CHAIN
@@ -89,7 +91,7 @@ export async function classifyAnomalies(
89
91
  await analyzeCollisions(partition, members, "slot", anomalies, divergences);
90
92
  await analyzeCrossKindPositionReuse(partition, members, anomalies, divergences);
91
93
  await analyzePredecessorReuse(partition, members, byChainHash, anomalies, divergences);
92
- analyzeChainBreaks(partition, members, byChainHash, partitionOf, anomalies);
94
+ analyzeChainBreaks(partition, members, byChainHash, partitionOf, anomalies, boundaryEntryPoints);
93
95
  await analyzeMultipleGenesis(partition, members, anomalies, divergences);
94
96
  analyzeSlotOrder(partition, members, anomalies);
95
97
  }
@@ -99,7 +101,7 @@ export async function classifyAnomalies(
99
101
  // happened upstream in reconstruct.ts against chain hashes.
100
102
  await analyzeEpochLinks(reconstruction.epochRelationships.edges, byHash, anomalies, divergences);
101
103
 
102
- return { anomalies, divergences };
104
+ return { anomalies, divergences, boundaryEntryPoints };
103
105
  }
104
106
 
105
107
  // ---------------------------------------------------------------------------
@@ -385,10 +387,20 @@ function analyzeChainBreaks(
385
387
  members: ObservedProof[],
386
388
  byChainHash: Map<string, ObservedProof>,
387
389
  partitionOf: Map<string, PartitionKey>,
388
- anomalies: ChainAnomaly[]
390
+ anomalies: ChainAnomaly[],
391
+ boundaryEntryPoints: BoundaryEntryPoint[]
389
392
  ): void {
390
393
  // prevB64 resolves in-partition when it equals a member's CHAIN hash.
391
394
  const memberChainHashes = new Set(members.map((m) => m.chainHash));
395
+ // Lowest observed commit counter: a dangling link AT the lowest position is
396
+ // the excerpt's frontier (an expected boundary); a dangling link ABOVE it is
397
+ // an interior hole (a real chain break, and the missing proof's positions
398
+ // also surface as unexplained-counter-positions).
399
+ let minCounter: bigint | undefined;
400
+ for (const m of members) {
401
+ const c = parseCounter(m.counter);
402
+ if (c !== undefined && (minCounter === undefined || c < minCounter)) minCounter = c;
403
+ }
392
404
  for (const m of [...members].sort(byCounterThenHash)) {
393
405
  if (m.prevB64 === undefined) continue;
394
406
  if (memberChainHashes.has(m.prevB64)) continue; // resolved in-partition
@@ -433,14 +445,42 @@ function analyzeChainBreaks(
433
445
  continue;
434
446
  }
435
447
 
448
+ // A well-formed, in-partition prevB64 whose predecessor is absent splits
449
+ // into two cases by position:
450
+ //
451
+ // - FRONTIER (m is at the lowest observed counter): the excerpt simply
452
+ // starts here; its predecessor precedes the exported window. This is the
453
+ // EXPECTED boundary of any bounded bundle, not a defect. A validly
454
+ // signed, attested proof only exists by extending the chain (fail-closed
455
+ // construction), so its predecessor did exist; it is just not included.
456
+ // Recorded as an informational boundary entry point — never sets the
457
+ // exit code, never marks the partition non-intact. A full-epoch export
458
+ // has none of these (its earliest proof is the genesis, no prevB64).
459
+ //
460
+ // - INTERIOR (a lower-counter member is present): a proof sits below m yet
461
+ // the link into m is broken — a genuine hole. Stays a chain-break-missing
462
+ // anomaly. The missing proof's own positions also surface as
463
+ // unexplained-counter-positions, so an interior hole always fails.
464
+ const c = parseCounter(m.counter);
465
+ const isFrontier = c === undefined || minCounter === undefined || c <= minCounter;
466
+ if (isFrontier) {
467
+ boundaryEntryPoints.push({
468
+ partition: partition.key,
469
+ proofHash: m.proofHash,
470
+ prevB64: m.prevB64,
471
+ });
472
+ continue;
473
+ }
474
+
436
475
  anomalies.push({
437
476
  code: "chain-break-missing",
438
477
  partition: partition.key,
439
478
  proofHashes: [m.proofHash],
440
479
  message:
441
- "commit.prevB64 references a predecessor proof that is absent from the supplied bundle. The " +
442
- "chain cannot be reconstructed across this link from the supplied evidence; this does not, by " +
443
- "itself, establish that the predecessor never existed.",
480
+ "commit.prevB64 references a predecessor proof that is absent from the supplied bundle, and a " +
481
+ "lower-positioned proof IS present, so this is an interior break (a hole), not the excerpt's " +
482
+ "starting boundary. The chain cannot be reconstructed across this link from the supplied " +
483
+ "evidence; this does not, by itself, establish that the predecessor never existed.",
444
484
  details: { prevB64: m.prevB64 },
445
485
  });
446
486
  }
package/src/audit.ts CHANGED
@@ -17,7 +17,6 @@
17
17
  * Zero network access, as everywhere in this package.
18
18
  */
19
19
 
20
- import { readFileSync } from "node:fs";
21
20
  import { ingestBundle } from "./ingest.js";
22
21
  import { verifyObservedProofs } from "./verify-tiers.js";
23
22
  import { reconstructChains } from "./reconstruct.js";
@@ -27,28 +26,49 @@ import { identifyAnchors } from "./anchors.js";
27
26
  import { verifyAnchorWitnesses } from "./witness.js";
28
27
  import { deriveTemporalBounds } from "./temporal.js";
29
28
  import { validateAttestations } from "./attestation.js";
30
- import type { AuditOptions, AuditResult, ExitFlags } from "./types.js";
29
+ import type { AuditOptions, AuditResult, ExitFlags, IngestResult } from "./types.js";
31
30
 
32
- let cachedToolVersion: string | undefined;
31
+ /**
32
+ * The audit package's own version, as a source constant rather than a
33
+ * runtime package.json read. The read had two failure modes in bundled
34
+ * embedders (esbuild/webpack output, browsers): a foreign package.json one
35
+ * level up supplies the WRONG version into every report, or there is no
36
+ * package.json at all and the read throws mid-audit. A unit test asserts
37
+ * this equals package.json's version, so the constant cannot drift silently
38
+ * across releases.
39
+ */
40
+ export const AUDIT_VERSION = "0.2.0";
33
41
 
34
- /** The audit package's own version, read once from its package.json. */
42
+ /** The audit package's own version. */
35
43
  export function auditToolVersion(): string {
36
- if (cachedToolVersion === undefined) {
37
- const raw = readFileSync(new URL("../package.json", import.meta.url), "utf8");
38
- cachedToolVersion = (JSON.parse(raw) as { version: string }).version;
39
- }
40
- return cachedToolVersion;
44
+ return AUDIT_VERSION;
45
+ }
46
+
47
+ /** Options for auditIngest(): the audit options plus the run stamp. */
48
+ export interface AuditIngestOptions extends AuditOptions {
49
+ /**
50
+ * The runMetadata.startedAt value. runAudit() stamps the wall clock here;
51
+ * an embedder that needs a fully deterministic result supplies its own
52
+ * (any string, for example ""). Defaults to the wall clock when omitted.
53
+ */
54
+ startedAt?: string;
41
55
  }
42
56
 
43
57
  /**
44
- * Run the complete audit pipeline over a bundle (directory, .tar, .tar.gz,
45
- * or .tgz) and return everything every stage produced.
58
+ * The pure tail of the pipeline over an already-ingested bundle: every
59
+ * stage after ingest, in canonical order, with no filesystem access. This
60
+ * is how a browser or an embedder that used ingestEntries() gets the same
61
+ * AuditResult the CLI gets from a path. runAudit() is exactly
62
+ * ingestBundle() followed by this.
46
63
  */
47
- export async function runAudit(bundlePath: string, options?: AuditOptions): Promise<AuditResult> {
48
- // The ONLY wall-clock read in the pipeline. See AuditRunMetadata.
49
- const startedAt = new Date().toISOString();
64
+ export async function auditIngest(
65
+ ingest: IngestResult,
66
+ options?: AuditIngestOptions
67
+ ): Promise<AuditResult> {
68
+ // The ONLY wall-clock read in the pipeline, and only when the caller did
69
+ // not supply the stamp. See AuditRunMetadata.
70
+ const startedAt = options?.startedAt ?? new Date().toISOString();
50
71
 
51
- const ingest = await ingestBundle(bundlePath);
52
72
  const verification = await verifyObservedProofs(
53
73
  ingest,
54
74
  options?.trustAnchors !== undefined ? { trustAnchors: options.trustAnchors } : undefined
@@ -72,7 +92,7 @@ export async function runAudit(bundlePath: string, options?: AuditOptions): Prom
72
92
  runMetadata: {
73
93
  toolVersion: auditToolVersion(),
74
94
  startedAt,
75
- bundlePath,
95
+ bundlePath: ingest.bundlePath,
76
96
  container: ingest.container,
77
97
  },
78
98
  ingest,
@@ -87,6 +107,15 @@ export async function runAudit(bundlePath: string, options?: AuditOptions): Prom
87
107
  };
88
108
  }
89
109
 
110
+ /**
111
+ * Run the complete audit pipeline over a bundle (directory, .tar, .tar.gz,
112
+ * or .tgz) and return everything every stage produced.
113
+ */
114
+ export async function runAudit(bundlePath: string, options?: AuditOptions): Promise<AuditResult> {
115
+ const ingest = await ingestBundle(bundlePath);
116
+ return auditIngest(ingest, options);
117
+ }
118
+
90
119
  /**
91
120
  * Anchor witness verification-failure codes: every finding the witness
92
121
  * stage emits is a failure (verifyAnchorWitnesses records findings only for
package/src/index.ts CHANGED
@@ -45,6 +45,7 @@ export type {
45
45
  ReconstructionResult,
46
46
  ChainAnomaly,
47
47
  UnexplainedPositionsDetail,
48
+ BoundaryEntryPoint,
48
49
  DivergenceKind,
49
50
  DivergenceParty,
50
51
  DivergenceRecord,
@@ -84,7 +85,8 @@ export type {
84
85
  AuditJsonReport,
85
86
  } from "./types.js";
86
87
 
87
- export { ingestBundle, streamMatchedArtifacts, DEFAULT_INGEST_LIMITS } from "./ingest.js";
88
+ export { ingestBundle, ingestEntries, streamMatchedArtifacts, DEFAULT_INGEST_LIMITS } from "./ingest.js";
89
+ export type { BundleEntrySource } from "./ingest.js";
88
90
 
89
91
  export { verifyObservedProofs } from "./verify-tiers.js";
90
92
 
@@ -102,7 +104,8 @@ export { deriveTemporalBounds } from "./temporal.js";
102
104
 
103
105
  export { validateAttestations, validateNitroAttestationDocument } from "./attestation.js";
104
106
 
105
- export { runAudit, computeExitFlags, auditToolVersion } from "./audit.js";
107
+ export { runAudit, auditIngest, computeExitFlags, auditToolVersion, AUDIT_VERSION } from "./audit.js";
108
+ export type { AuditIngestOptions } from "./audit.js";
106
109
 
107
110
  export { buildJsonReport } from "./report-json.js";
108
111
 
package/src/ingest.ts CHANGED
@@ -160,6 +160,141 @@ export async function ingestBundle(
160
160
  strippedRootPrefix = rootTracker.commonRoot();
161
161
  }
162
162
 
163
+ return finalizeIngest({
164
+ bundlePath,
165
+ container,
166
+ scanned,
167
+ strippedRootPrefix,
168
+ findings,
169
+ entriesScanned,
170
+ skippedUnsafePaths,
171
+ });
172
+ }
173
+
174
+ /**
175
+ * One in-memory bundle entry for ingestEntries(): a bundle-root-relative
176
+ * path (forward slashes) and a way to open its bytes. `open` may be called
177
+ * more than once (once to scan and hash, again to re-read matched artifact
178
+ * bytes for full-tier verification), and it may return the bytes whole,
179
+ * a promise of them, or an async chunk stream, so a browser can hand over
180
+ * File objects without buffering every artifact up front.
181
+ */
182
+ export interface BundleEntrySource {
183
+ path: string;
184
+ open: () => Uint8Array | Promise<Uint8Array> | AsyncIterable<Uint8Array>;
185
+ }
186
+
187
+ /**
188
+ * Per-result registry of entry sources for in-memory ("memory" container)
189
+ * ingests, so streamMatchedArtifacts can re-read artifact bytes without the
190
+ * IngestResult carrying functions. Keyed by identity: a structurally cloned
191
+ * IngestResult loses its sources and yields no artifact bytes, which
192
+ * downgrades every proof to the integrity tier rather than crashing.
193
+ */
194
+ const memorySources = new WeakMap<IngestResult, Map<string, BundleEntrySource>>();
195
+
196
+ /**
197
+ * Ingest a bundle from in-memory entries: the filesystem-free counterpart
198
+ * of ingestBundle(), for browsers and embedders that already hold the
199
+ * bytes. Same discovery, classification, hashing, and matching, so the
200
+ * result feeds the same verification and reconstruction stages. Entries
201
+ * are ordered by path before scanning so the result is deterministic
202
+ * regardless of the order the caller supplied them; paths are normalized
203
+ * exactly as tar entries are (unsafe paths are skipped and reported).
204
+ * Performs no verification and no network access.
205
+ */
206
+ export async function ingestEntries(
207
+ entries: Iterable<BundleEntrySource>,
208
+ options?: { label?: string }
209
+ ): Promise<IngestResult> {
210
+ const findings: AuditFinding[] = [];
211
+ let skippedUnsafePaths = 0;
212
+ let entriesScanned = 0;
213
+ const scanned: ScannedEntry[] = [];
214
+ const sources = new Map<string, BundleEntrySource>();
215
+
216
+ const ordered = Array.from(entries).sort((a, b) =>
217
+ a.path < b.path ? -1 : a.path > b.path ? 1 : 0
218
+ );
219
+ for (const entry of ordered) {
220
+ entriesScanned++;
221
+ const normalized = normalizeEntryPath(entry.path);
222
+ if (normalized.unsafe) {
223
+ skippedUnsafePaths++;
224
+ findings.push({
225
+ code: "unsafe-path",
226
+ path: entry.path,
227
+ message: `entry skipped: ${normalized.reason}`,
228
+ });
229
+ continue;
230
+ }
231
+ const hashed = await hashEntryStream(
232
+ normalized.path,
233
+ undefined,
234
+ undefined,
235
+ openAsChunks(entry.open())
236
+ );
237
+ scanned.push(makeScannedEntry(normalized.path, hashed));
238
+ sources.set(normalized.path, entry);
239
+ }
240
+
241
+ const result = finalizeIngest({
242
+ bundlePath: options?.label ?? "",
243
+ container: "memory",
244
+ scanned,
245
+ strippedRootPrefix: undefined,
246
+ findings,
247
+ entriesScanned,
248
+ skippedUnsafePaths,
249
+ });
250
+ memorySources.set(result, sources);
251
+ return result;
252
+ }
253
+
254
+ /** Adapt every accepted `open()` return shape to a chunk stream. */
255
+ async function* openAsChunks(
256
+ opened: Uint8Array | Promise<Uint8Array> | AsyncIterable<Uint8Array>
257
+ ): AsyncGenerator<Uint8Array, void, void> {
258
+ const value = await Promise.resolve(opened as Uint8Array | Promise<Uint8Array>);
259
+ if (value instanceof Uint8Array) {
260
+ yield value;
261
+ return;
262
+ }
263
+ // Not a Uint8Array and not a promise of one: an async iterable.
264
+ for await (const chunk of opened as AsyncIterable<Uint8Array>) yield chunk;
265
+ }
266
+
267
+ /** Read an in-memory entry whole for artifact re-reads. */
268
+ async function readSourceWhole(source: BundleEntrySource): Promise<Uint8Array> {
269
+ const chunks: Uint8Array[] = [];
270
+ let total = 0;
271
+ for await (const chunk of openAsChunks(source.open())) {
272
+ chunks.push(chunk);
273
+ total += chunk.length;
274
+ }
275
+ return concatBytes(chunks, total);
276
+ }
277
+
278
+ interface FinalizeParams {
279
+ bundlePath: string;
280
+ container: ContainerKind;
281
+ scanned: ScannedEntry[];
282
+ strippedRootPrefix: string | undefined;
283
+ findings: AuditFinding[];
284
+ entriesScanned: number;
285
+ skippedUnsafePaths: number;
286
+ }
287
+
288
+ /**
289
+ * The container-independent tail of ingest: path finalization, the
290
+ * contents hash, classification by schema shape, and content-addressed
291
+ * artifact matching. Both ingestBundle() and ingestEntries() end here, so
292
+ * a directory, an archive, and an in-memory entry set that hold the same
293
+ * bytes at the same paths classify identically.
294
+ */
295
+ function finalizeIngest(params: FinalizeParams): IngestResult {
296
+ const { bundlePath, container, scanned, strippedRootPrefix, findings, entriesScanned, skippedUnsafePaths } = params;
297
+
163
298
  const finalByPath = new Map<string, FinalEntry>();
164
299
  for (const entry of scanned) {
165
300
  let finalPath = entry.rawPath;
@@ -318,6 +453,30 @@ export async function* streamMatchedArtifacts(
318
453
  const needed = ingest.artifacts.filter((a) => a.matchedProofHashes.length > 0);
319
454
  if (needed.length === 0) return;
320
455
 
456
+ if (ingest.container === "memory") {
457
+ // In-memory ingest: re-open through the registered sources. A result
458
+ // whose sources are unknown (cloned, or not from ingestEntries) yields
459
+ // nothing, so verification falls back to the integrity tier.
460
+ const sources = memorySources.get(ingest);
461
+ if (sources === undefined) return;
462
+ for (const artifact of needed) {
463
+ for (const relPath of artifact.paths) {
464
+ const source = sources.get(relPath);
465
+ if (source === undefined) continue;
466
+ let bytes: Uint8Array;
467
+ try {
468
+ bytes = await readSourceWhole(source);
469
+ } catch {
470
+ continue;
471
+ }
472
+ if (toHex(sha256(bytes)) !== artifact.sha256Hex) continue;
473
+ yield { sha256Hex: artifact.sha256Hex, path: relPath, bytes };
474
+ break;
475
+ }
476
+ }
477
+ return;
478
+ }
479
+
321
480
  if (ingest.container === "directory") {
322
481
  for (const artifact of needed) {
323
482
  for (const relPath of artifact.paths) {
@@ -104,6 +104,7 @@ export function buildJsonReport(result: AuditResult): AuditJsonReport {
104
104
  },
105
105
  anomalies,
106
106
  divergences: result.anomalies.divergences,
107
+ boundaryEntryPoints: result.anomalies.boundaryEntryPoints,
107
108
  authorities: {
108
109
  groups: result.authorities.groups,
109
110
  sharedSignersAcrossEpochs: result.authorities.sharedSignersAcrossEpochs,
@@ -307,6 +308,7 @@ function buildSummary(
307
308
  epochsObserved: result.reconstruction.epochRelationships.epochs.length,
308
309
  anomalyCountsByCode,
309
310
  divergenceCount: result.anomalies.divergences.length,
311
+ boundaryEntryPoints: result.anomalies.boundaryEntryPoints.length,
310
312
  authorityGroupCount: result.authorities.groups.length,
311
313
  distinctSignerCount: signers.size,
312
314
  distinctDeclaredMeasurementCount: measurements.size,
package/src/report-md.ts CHANGED
@@ -252,6 +252,23 @@ function executiveSummary(
252
252
  }
253
253
  lines.push("");
254
254
 
255
+ // Boundary entry points (expected excerpt frontiers, not anomalies).
256
+ const boundaries = report.boundaryEntryPoints.length;
257
+ if (boundaries > 0) {
258
+ lines.push("### Bundle boundaries");
259
+ lines.push("");
260
+ lines.push(
261
+ `${withCommas(boundaries)} ${plural(boundaries, "proof is", "proofs are")} the earliest of a ` +
262
+ "reconstructed chain and reference a predecessor that precedes the exported window. This is the " +
263
+ "expected boundary of a bounded excerpt (a single-file proof bundle or a small batch), not a " +
264
+ "chain-integrity defect: a validly signed, attested proof only comes into existence by extending " +
265
+ "the chain, so its predecessor did exist and is simply not included here. Boundaries never set the " +
266
+ "exit code. A full-epoch export has none (its earliest proof is the epoch genesis, which has no " +
267
+ "predecessor link)."
268
+ );
269
+ lines.push("");
270
+ }
271
+
255
272
  // Divergences.
256
273
  lines.push("### Divergences");
257
274
  lines.push("");
package/src/types.ts CHANGED
@@ -385,7 +385,11 @@ export interface ManifestReport {
385
385
  // ---------------------------------------------------------------------------
386
386
 
387
387
  /** Accepted container forms per the bundle spec section 4. */
388
- export type ContainerKind = "directory" | "tar" | "tar-gz";
388
+ /**
389
+ * "memory" is an ingestEntries() result: entries supplied by the caller
390
+ * with no container on disk. bundlePath is then the caller's label (or "").
391
+ */
392
+ export type ContainerKind = "directory" | "tar" | "tar-gz" | "memory";
389
393
 
390
394
  export interface IngestCounts {
391
395
  /** Unique observed proofs by canonical identity. */
@@ -758,6 +762,36 @@ export interface ChainAnomaly {
758
762
  details?: Record<string, unknown>;
759
763
  }
760
764
 
765
+ /**
766
+ * A chain link whose predecessor is absent from the supplied bundle: the
767
+ * earliest proof of a reconstructed run points to history that precedes the
768
+ * exported window.
769
+ *
770
+ * This is the EXPECTED boundary of any bounded excerpt (a single-file proof
771
+ * bundle, a small batch), NOT a chain-integrity defect: a validly signed,
772
+ * attested proof only comes into existence by extending the chain (the
773
+ * fail-closed construction property), so its own existence is evidence it
774
+ * occupied a real position; the predecessor is simply not included in this
775
+ * export. A full-epoch export has NO boundary entry points, its earliest
776
+ * proof is the epoch genesis, which carries no prevB64 at all. An INTERIOR
777
+ * hole never masquerades as a boundary: a missing mid-chain proof both
778
+ * fragments the component (so the successor becomes its own component's
779
+ * boundary) AND surfaces as an `unexplained-counter-positions` anomaly within
780
+ * the observed range, which still trips the exit code.
781
+ *
782
+ * Boundary entry points are informational: they never set the exit code and
783
+ * never mark a partition non-intact. The count is surfaced in the summary so a
784
+ * full-epoch audit (which expects zero) makes a stray boundary visible.
785
+ */
786
+ export interface BoundaryEntryPoint {
787
+ /** The partition this boundary is scoped to. */
788
+ partition: PartitionKey;
789
+ /** The proof whose commit.prevB64 references a predecessor absent from the bundle. */
790
+ proofHash: string;
791
+ /** The unresolved predecessor chain hash (standard base64). */
792
+ prevB64: string;
793
+ }
794
+
761
795
  /**
762
796
  * Detail payload of an "unexplained-counter-positions" anomaly (G2).
763
797
  * A position is explained when it is some observed proof's commit counter
@@ -833,6 +867,12 @@ export interface AnomalyReport {
833
867
  anomalies: ChainAnomaly[];
834
868
  /** Divergence records for every conflict between valid proofs. */
835
869
  divergences: DivergenceRecord[];
870
+ /**
871
+ * Expected excerpt boundaries: a bundle's earliest proofs whose predecessor
872
+ * precedes the exported window. Informational only, never affects the exit
873
+ * code or partition intactness. Zero for a full-epoch export.
874
+ */
875
+ boundaryEntryPoints: BoundaryEntryPoint[];
836
876
  }
837
877
 
838
878
  // ---------------------------------------------------------------------------
@@ -1448,6 +1488,13 @@ export interface ReportSummary {
1448
1488
  /** Anomaly counts keyed by stable code, keys sorted. */
1449
1489
  anomalyCountsByCode: Record<string, number>;
1450
1490
  divergenceCount: number;
1491
+ /**
1492
+ * Count of expected excerpt boundaries (earliest proofs whose predecessor
1493
+ * precedes the exported window). Informational, never a failure. A
1494
+ * full-epoch export has zero; a bounded proof bundle has one per included
1495
+ * chain segment.
1496
+ */
1497
+ boundaryEntryPoints: number;
1451
1498
  authorityGroupCount: number;
1452
1499
  distinctSignerCount: number;
1453
1500
  distinctDeclaredMeasurementCount: number;
@@ -1506,6 +1553,8 @@ export interface AuditJsonReport {
1506
1553
  /** Unified anomaly list: stage order (ingest, chain, authority, anchor, witness, attestation), stage-internal detection order. */
1507
1554
  anomalies: ReportAnomaly[];
1508
1555
  divergences: DivergenceRecord[];
1556
+ /** Expected excerpt boundaries (informational; never a failure). Empty for a full-epoch export. */
1557
+ boundaryEntryPoints: BoundaryEntryPoint[];
1509
1558
  authorities: {
1510
1559
  groups: AuthorityGroup[];
1511
1560
  sharedSignersAcrossEpochs: SignerEpochSpan[];