attenu-guard 0.5.0 → 0.6.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/CHANGELOG.md CHANGED
@@ -6,6 +6,41 @@ follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.6.0] - 2026-09-02
10
+
11
+ ### Added
12
+ - **The bundle-level interop test vectors, and the structured failures they are scored
13
+ against.** The delegation-chain vectors under `test/fixtures/vectors/` have always let this
14
+ package score its own *token* verifier against the Python reference. There was no equivalent
15
+ for the *bundle* verifier, which is where the offline-verifiability claim actually lands: an
16
+ auditor checks a published ledger with no engine, no service and no vendor in the loop.
17
+ `test/fixtures/vectors/bundles/bundle_vectors_v1.json` is that file, copied byte for byte from
18
+ the Python repository (`tests/vectors/bundles/bundle_vectors_v1.json`, whose single writer is
19
+ `tests/vectors/generate_bundles.py`). Eight cases, every one derived from a single valid
20
+ schema-v2 bundle by exactly ONE change so each isolates one rule: `valid_bundle_v2` (accept),
21
+ `reject_params_mismatch`, `reject_outcome_without_allow`, `reject_outcome_before_allow`,
22
+ `reject_duplicate_outcome`, `reject_duplicate_call_id`, `reject_rehashed_chain` (one entry
23
+ edited and every later hash recomputed — the rewrite a hash chain alone cannot catch, which
24
+ only the signed anchor does) and `reject_tampered_entry` (the same edit with nothing
25
+ re-hashed, which fails AT that entry). All eight score here exactly as they score in Python,
26
+ with byte-identical `failures` strings. `tools/gen_fixtures.py` re-copies the file from the
27
+ installed Python package once a release ships it, so CI's existing fixture-drift check starts
28
+ guarding it with no workflow edit.
29
+ - **`verifyBundle()` reports `failure_details`, the structured twin of `failures`** (additive;
30
+ `failures` is byte-identical to before, because other implementations parse those strings).
31
+ One entry per string, same order, same count: `{reason, seq, node, call_id, detail}`, so a
32
+ conformance suite can assert WHICH check failed and WHERE instead of matching prose. `reason`
33
+ is the token before the colon in the message, except at the two historical sites whose message
34
+ names a node there (`unreadable_authority`, `unreadable_granted`), which state their reason
35
+ explicitly. Positions match the Python implementation's: the second sighting for a re-used
36
+ `call_id`, the `outcome` entry for every allow/outcome binding failure, and null for a
37
+ genuinely chain-level failure such as a signed anchor that no longer matches the ledger head.
38
+ Every failure in `src/evidence.ts` now goes through one collector, so a message cannot be
39
+ added without its twin — `test/bundle-vectors.test.ts` asserts the two lists stay in step at
40
+ every failure site in the module (including the ones no vector exercises) and traps a direct
41
+ append. The `execution_binding` sub-report's own shape is unchanged: the twins ride alongside
42
+ it, never inside it.
43
+
9
44
  ## [0.5.0] - 2026-08-31
10
45
 
11
46
  ### Fixed
package/README.md CHANGED
@@ -178,6 +178,17 @@ dual-signing mode. The 19 committed interop vectors include the separating cases
178
178
  for number spelling, raw Unicode, UTF-16 member ordering, large integers,
179
179
  duplicates, non-finite values and an unmarked canonical header.
180
180
 
181
+ `test/fixtures/vectors/bundles/bundle_vectors_v1.json` is the second, bundle-level
182
+ suite: whole evidence bundles for `verifyBundle`, the check an auditor runs on a
183
+ published ledger with no engine and no vendor in the loop. The token vectors pin
184
+ what a delegation token means; these pin what the LEDGER of a run has to satisfy.
185
+ A bundle verifier reports a LIST of failures rather than one reject reason, so
186
+ each rejecting case declares the minimal set of `{reason, seq, node}` that MUST
187
+ appear, at that exact position. A conformant verifier may report more, never
188
+ fewer and never elsewhere. `verifyBundle` returns those positions as
189
+ `failure_details`, the structured twin of `failures`: one
190
+ `{reason, seq, node, call_id, detail}` entry per string, in the same order.
191
+
181
192
  ## What it does not do
182
193
 
183
194
  - It does not decide what permissions a task needs. You state them; this library
@@ -18,6 +18,14 @@
18
18
  * const bundle = exportBundle(guard.auditLog(), signer);
19
19
  * const report = verifyBundle(bundle, signer);
20
20
  *
21
+ * `report.failures` is the human-readable list — its strings are a published
22
+ * contract, other implementations parse them — and `report.failure_details` is
23
+ * its machine-readable twin: one entry per string, same order, same count,
24
+ * `{reason, seq, node, call_id, detail}`. It exists so a conformance suite can
25
+ * assert WHICH check failed and WHERE, not merely that something did. The
26
+ * bundle-level interop vectors under `test/fixtures/vectors/bundles/` are scored
27
+ * against exactly that shape.
28
+ *
21
29
  * No engine state is consulted — the bundle is the whole input, which is the
22
30
  * point. This is byte-compatible with the Python library's
23
31
  * `attenu_guard.evidence`.
@@ -98,6 +106,23 @@ export interface ExportOptions {
98
106
  * is thrown on any field outside it.
99
107
  */
100
108
  export declare function exportBundle(auditLog: AuditLog | readonly LedgerEntry[], signer: Signer, options?: ExportOptions): Bundle;
109
+ /**
110
+ * One structured failure — the machine-readable twin of one `failures` string.
111
+ *
112
+ * `reason` is a stable token: the text before the first `:` in `detail`, with
113
+ * the two historical exceptions whose message names a NODE there
114
+ * (`unreadable_authority`, `unreadable_granted`) and so state their reason
115
+ * explicitly. `seq`/`node` are the offending entry's own fields, both `null`
116
+ * when the failure is chain-level with nothing single to point at. Same field
117
+ * names and same values as the Python implementation's `failure_details`.
118
+ */
119
+ export interface FailureDetail {
120
+ reason: string;
121
+ seq: Json;
122
+ node: Json;
123
+ call_id: Json;
124
+ detail: string;
125
+ }
101
126
  export interface GraphNode {
102
127
  agent: Json;
103
128
  task: Json;
@@ -165,6 +190,12 @@ export interface VerifyReport {
165
190
  ok: boolean;
166
191
  checks: VerifyChecks;
167
192
  failures: string[];
193
+ /**
194
+ * The structured twin of `failures`: same order, same count, one
195
+ * `{reason, seq, node, call_id, detail}` per string, so a conformance suite can assert the
196
+ * reason AND the position of every failure instead of matching prose.
197
+ */
198
+ failure_details: FailureDetail[];
168
199
  nodes: number;
169
200
  actions_checked: number;
170
201
  chain_id: Json;
@@ -190,6 +221,9 @@ export interface VerifyReport {
190
221
  * the bundle's actual `(seq, hash, chainId, v)` must equal it exactly, or `checks.expected_anchor`
191
222
  * reports `"FAILED"` and the mismatch lands in `failures`. `report.verified_against` names which
192
223
  * mode ran.
224
+ *
225
+ * `failure_details` is the structured twin of `failures`: same order, same count, one
226
+ * `{reason, seq, node, call_id, detail}` entry per string.
193
227
  */
194
228
  export interface VerifyBundleOptions {
195
229
  /** An independently retained anchor object to verify the bundle's actual head against. */
@@ -1 +1 @@
1
- {"version":3,"file":"evidence.d.ts","sourceRoot":"","sources":["../../src/evidence.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"evidence.d.ts","sourceRoot":"","sources":["../../src/evidence.ts"],"names":[],"mappings":"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"}
@@ -19,6 +19,14 @@
19
19
  * const bundle = exportBundle(guard.auditLog(), signer);
20
20
  * const report = verifyBundle(bundle, signer);
21
21
  *
22
+ * `report.failures` is the human-readable list — its strings are a published
23
+ * contract, other implementations parse them — and `report.failure_details` is
24
+ * its machine-readable twin: one entry per string, same order, same count,
25
+ * `{reason, seq, node, call_id, detail}`. It exists so a conformance suite can
26
+ * assert WHICH check failed and WHERE, not merely that something did. The
27
+ * bundle-level interop vectors under `test/fixtures/vectors/bundles/` are scored
28
+ * against exactly that shape.
29
+ *
22
30
  * No engine state is consulted — the bundle is the whole input, which is the
23
31
  * point. This is byte-compatible with the Python library's
24
32
  * `attenu_guard.evidence`.
@@ -228,6 +236,33 @@ function orNull(value) {
228
236
  const plain = (0, canonical_js_1.toPlain)(value);
229
237
  return plain === undefined ? null : plain;
230
238
  }
239
+ /**
240
+ * The verifier's failure list, kept in two shapes that cannot drift apart.
241
+ *
242
+ * `messages` is the string list `verifyBundle` has always returned as
243
+ * `failures`; those exact strings are a published contract, so they are never
244
+ * reworded here. `details` is the structured twin of each one, appended in the
245
+ * same call. Every failure in this module goes through `add`, so a new check
246
+ * cannot add a message without its twin — `test/bundle-vectors.test.ts` greps
247
+ * this file for a direct append to a failure list and fails on one, and asserts
248
+ * the two lists stay in step at every site.
249
+ */
250
+ class FailureLog {
251
+ messages = [];
252
+ details = [];
253
+ add(reason, detail, position = {}) {
254
+ const { seq = null, node = null, callId = null } = position;
255
+ this.messages.push(detail);
256
+ this.details.push({ reason, seq, node, call_id: callId, detail });
257
+ }
258
+ extend(other) {
259
+ this.messages.push(...other.messages);
260
+ this.details.push(...other.details);
261
+ }
262
+ get length() {
263
+ return this.messages.length;
264
+ }
265
+ }
231
266
  /**
232
267
  * `node -> Authority` and `node -> parent`, reconstructed from `root` and
233
268
  * `spawn` events alone. No engine state.
@@ -235,29 +270,40 @@ function orNull(value) {
235
270
  function nodeAuthorities(entries) {
236
271
  const auth = new Map();
237
272
  const parent = new Map();
238
- const failures = [];
273
+ const failures = new FailureLog();
274
+ const definedBy = new Map();
239
275
  for (const e of entries) {
240
276
  const ev = (0, canonical_js_1.toPlain)(e["event"]);
241
277
  const node = (0, canonical_js_1.toPlain)(e["node"]);
242
278
  if (ev === "root") {
279
+ definedBy.set(node, e);
243
280
  try {
244
281
  auth.set(node, authority_js_1.Authority.fromWire(e["authority"] ?? null));
245
282
  }
246
283
  catch (exc) {
247
- failures.push(`root ${node}: unreadable authority (${exc.message})`);
284
+ // One of the two historical messages that name a node before their colon rather than a
285
+ // reason token, so the reason is stated here instead of parsed out of the string.
286
+ failures.add("unreadable_authority", `root ${node}: unreadable authority (${exc.message})`, {
287
+ seq: orNull(e["seq"]),
288
+ node: orNull(e["node"]),
289
+ });
248
290
  }
249
291
  }
250
292
  else if (ev === "spawn") {
293
+ definedBy.set(node, e);
251
294
  parent.set(node, (0, canonical_js_1.toPlain)(e["parent"]) ?? null);
252
295
  try {
253
296
  auth.set(node, authority_js_1.Authority.fromWire(e["granted"] ?? null));
254
297
  }
255
298
  catch (exc) {
256
- failures.push(`spawn ${node}: unreadable granted (${exc.message})`);
299
+ failures.add("unreadable_granted", `spawn ${node}: unreadable granted (${exc.message})`, {
300
+ seq: orNull(e["seq"]),
301
+ node: orNull(e["node"]),
302
+ });
257
303
  }
258
304
  }
259
305
  }
260
- return { auth, parent, failures };
306
+ return { auth, parent, failures, definedBy };
261
307
  }
262
308
  /**
263
309
  * A view of the chain from the bundle: each node with its agent, task,
@@ -596,24 +642,31 @@ const V2_ONLY_FIELDS = [
596
642
  * invalid regardless of which field it is (merge-gate item 4/(c)).
597
643
  */
598
644
  function v2FieldLeaksOnV1(entries) {
599
- const failures = [];
645
+ const failures = new FailureLog();
600
646
  for (const e of entries) {
601
647
  const leaked = V2_ONLY_FIELDS.filter((f) => f in e).sort();
602
648
  if (leaked.length > 0) {
603
- failures.push(`v2_field_on_v1: seq=${pyRepr((0, canonical_js_1.toPlain)(e["seq"]))} event=${pyRepr((0, canonical_js_1.toPlain)(e["event"]))} ` +
604
- `carries v2-only field(s) ${JSON.stringify(leaked)} on a schemaVersion: 1 entry`);
649
+ failures.add("v2_field_on_v1", `v2_field_on_v1: seq=${pyRepr((0, canonical_js_1.toPlain)(e["seq"]))} event=${pyRepr((0, canonical_js_1.toPlain)(e["event"]))} ` +
650
+ `carries v2-only field(s) ${JSON.stringify(leaked)} on a schemaVersion: 1 entry`, { seq: orNull(e["seq"]), node: orNull(e["node"]) });
605
651
  }
606
652
  }
607
653
  return failures;
608
654
  }
655
+ /**
656
+ * `[the execution_binding report, its failures]`. The report's own `failures` key keeps its
657
+ * historical list-of-strings shape — the structured twins ride alongside it rather than inside
658
+ * it, so this sub-report's published shape is unchanged.
659
+ */
609
660
  function executionBinding(entries, bundleV) {
610
661
  if (bundleV === 1) {
611
662
  const leaked = v2FieldLeaksOnV1(entries);
612
- return leaked.length > 0 ? { status: "not applicable", failures: leaked } : { status: "not applicable" };
663
+ return leaked.length > 0
664
+ ? [{ status: "not applicable", failures: leaked.messages }, leaked]
665
+ : [{ status: "not applicable" }, new FailureLog()];
613
666
  }
614
667
  if (bundleV !== 2)
615
- return { status: "not applicable" };
616
- const failures = [];
668
+ return [{ status: "not applicable" }, new FailureLog()];
669
+ const failures = new FailureLog();
617
670
  const seenCallIds = new Map(); // callId -> [event, node, seq]
618
671
  const allows = new Map();
619
672
  const outcomes = new Map();
@@ -629,8 +682,12 @@ function executionBinding(entries, bundleV) {
629
682
  if (node !== null)
630
683
  nodes.add(node);
631
684
  const err = validateRoot(e);
632
- if (err)
633
- failures.push(`invalid_root: ${err} (seq ${pyRepr(seqForEvent)})`);
685
+ if (err) {
686
+ failures.add("invalid_root", `invalid_root: ${err} (seq ${pyRepr(seqForEvent)})`, {
687
+ seq: orNull(e["seq"]),
688
+ node: orNull(e["node"]),
689
+ });
690
+ }
634
691
  }
635
692
  else if (ev === "spawn") {
636
693
  if (node !== null)
@@ -644,8 +701,12 @@ function executionBinding(entries, bundleV) {
644
701
  for (const r of (0, canonical_js_1.toPlain)(e["revoked"]) ?? [])
645
702
  revokedNodes.add(r);
646
703
  const err = validateKill(e);
647
- if (err)
648
- failures.push(`invalid_kill: ${err} (seq ${pyRepr(seqForEvent)})`);
704
+ if (err) {
705
+ failures.add("invalid_kill", `invalid_kill: ${err} (seq ${pyRepr(seqForEvent)})`, {
706
+ seq: orNull(e["seq"]),
707
+ node: orNull(e["node"]),
708
+ });
709
+ }
649
710
  }
650
711
  if (ev === "allow" || ev === "deny") {
651
712
  const cid = (0, canonical_js_1.toPlain)(e["call_id"]);
@@ -653,8 +714,10 @@ function executionBinding(entries, bundleV) {
653
714
  if (cid !== null && cid !== undefined) {
654
715
  const prior = seenCallIds.get(cid);
655
716
  if (prior !== undefined) {
656
- failures.push(`duplicate_call_id: call_id ${cid} on seq ${pyRepr(seq)} (${ev}) already used at seq ` +
657
- `${pyRepr(prior[2])} (${prior[0]})`);
717
+ // Positioned on the SECOND sighting: the entry that re-used a call_id is the offending
718
+ // record, the first one having been legitimate when it was written.
719
+ failures.add("duplicate_call_id", `duplicate_call_id: call_id ${cid} on seq ${pyRepr(seq)} (${ev}) already used at seq ` +
720
+ `${pyRepr(prior[2])} (${prior[0]})`, { seq: orNull(e["seq"]), node: orNull(e["node"]), callId: cid });
658
721
  }
659
722
  else {
660
723
  seenCallIds.set(cid, [ev, node, seq]);
@@ -662,7 +725,11 @@ function executionBinding(entries, bundleV) {
662
725
  }
663
726
  const err = ev === "allow" ? validateAllow(e) : validateDeny(e);
664
727
  if (err) {
665
- failures.push(`invalid_${ev}: ${err} (seq ${pyRepr(seq)})`);
728
+ failures.add(`invalid_${ev}`, `invalid_${ev}: ${err} (seq ${pyRepr(seq)})`, {
729
+ seq: orNull(e["seq"]),
730
+ node: orNull(e["node"]),
731
+ callId: cid ?? null,
732
+ });
666
733
  if (ev === "allow" && cid !== null && cid !== undefined)
667
734
  invalidAllowIds.add(cid);
668
735
  continue;
@@ -675,12 +742,16 @@ function executionBinding(entries, bundleV) {
675
742
  const seq = (0, canonical_js_1.toPlain)(e["seq"]);
676
743
  const err = validateOutcome(e);
677
744
  if (err) {
678
- failures.push(`invalid_outcome: ${err} (seq ${pyRepr(seq)})`);
745
+ failures.add("invalid_outcome", `invalid_outcome: ${err} (seq ${pyRepr(seq)})`, {
746
+ seq: orNull(e["seq"]),
747
+ node: orNull(e["node"]),
748
+ callId: cid ?? null,
749
+ });
679
750
  continue;
680
751
  }
681
752
  if (cid !== null && outcomes.has(cid)) {
682
- failures.push(`duplicate_outcome: call_id ${cid} at seq ${pyRepr(seq)} (first at seq ` +
683
- `${pyRepr((0, canonical_js_1.toPlain)(outcomes.get(cid)["seq"]))})`);
753
+ failures.add("duplicate_outcome", `duplicate_outcome: call_id ${cid} at seq ${pyRepr(seq)} (first at seq ` +
754
+ `${pyRepr((0, canonical_js_1.toPlain)(outcomes.get(cid)["seq"]))})`, { seq: orNull(e["seq"]), node: orNull(e["node"]), callId: cid });
684
755
  continue;
685
756
  }
686
757
  if (cid !== null)
@@ -694,29 +765,32 @@ function executionBinding(entries, bundleV) {
694
765
  // its recorded content disagrees with what was authorized (spec: "parameter equality is
695
766
  // established only for calls where both hashes are present; elsewhere only identity and order
696
767
  // binding was checked" — params_mismatch is that separate concern).
768
+ // Every failure in this loop is about a PAIR, and is positioned on the `outcome` entry: the
769
+ // allow was a complete, valid record when it was written, and it is the outcome that fails to
770
+ // bind to it (or reports different arguments than were authorized).
697
771
  const boundOk = new Set();
698
772
  for (const [cid, oc] of outcomes) {
699
773
  const allowE = allows.get(cid);
700
774
  if (allowE === undefined) {
701
- failures.push(`outcome_without_allow: call_id ${cid} at seq ${pyRepr((0, canonical_js_1.toPlain)(oc["seq"]))} has no allow in this chain`);
775
+ failures.add("outcome_without_allow", `outcome_without_allow: call_id ${cid} at seq ${pyRepr((0, canonical_js_1.toPlain)(oc["seq"]))} has no allow in this chain`, { seq: orNull(oc["seq"]), node: orNull(oc["node"]), callId: cid });
702
776
  continue;
703
777
  }
704
778
  const nodeOk = (0, canonical_js_1.toPlain)(allowE["node"]) === (0, canonical_js_1.toPlain)(oc["node"]);
705
779
  if (!nodeOk) {
706
- failures.push(`cross_ref: call_id ${cid} allow on node ${pyRepr((0, canonical_js_1.toPlain)(allowE["node"]))} but ` +
707
- `outcome on node ${pyRepr((0, canonical_js_1.toPlain)(oc["node"]))}`);
780
+ failures.add("cross_ref", `cross_ref: call_id ${cid} allow on node ${pyRepr((0, canonical_js_1.toPlain)(allowE["node"]))} but ` +
781
+ `outcome on node ${pyRepr((0, canonical_js_1.toPlain)(oc["node"]))}`, { seq: orNull(oc["seq"]), node: orNull(oc["node"]), callId: cid });
708
782
  }
709
783
  const ocSeq = (0, canonical_js_1.toPlain)(oc["seq"]);
710
784
  const allowSeq = (0, canonical_js_1.toPlain)(allowE["seq"]);
711
785
  const orderOk = typeof ocSeq === "number" && typeof allowSeq === "number" && ocSeq > allowSeq;
712
786
  if (!orderOk) {
713
- failures.push(`outcome_before_allow: call_id ${cid} outcome seq ${pyRepr(ocSeq ?? null)} not ` +
714
- `after allow seq ${pyRepr(allowSeq ?? null)}`);
787
+ failures.add("outcome_before_allow", `outcome_before_allow: call_id ${cid} outcome seq ${pyRepr(ocSeq ?? null)} not ` +
788
+ `after allow seq ${pyRepr(allowSeq ?? null)}`, { seq: orNull(oc["seq"]), node: orNull(oc["node"]), callId: cid });
715
789
  }
716
790
  const ah = (0, canonical_js_1.toPlain)(allowE["authorized_params_hash"]);
717
791
  const ih = (0, canonical_js_1.toPlain)(oc["invoked_params_hash"]);
718
792
  if (ah !== null && ah !== undefined && ih !== null && ih !== undefined && ah !== ih) {
719
- failures.push(`params_mismatch: call_id ${cid} authorized_params_hash ${ah} != invoked_params_hash ${ih}`);
793
+ failures.add("params_mismatch", `params_mismatch: call_id ${cid} authorized_params_hash ${ah} != invoked_params_hash ${ih}`, { seq: orNull(oc["seq"]), node: orNull(oc["node"]), callId: cid });
720
794
  }
721
795
  if (nodeOk && orderOk)
722
796
  boundOk.add(cid);
@@ -786,13 +860,52 @@ function executionBinding(entries, bundleV) {
786
860
  }
787
861
  if (Object.values(perCall).some((s) => s === "unobserved"))
788
862
  escalate("incomplete");
789
- return {
790
- aggregate,
791
- params_coverage: paramsCoverage(allows, outcomes, invalidAllowIds),
792
- per_call: perCall,
793
- per_node_lifecycle: lifecycle,
863
+ return [
864
+ {
865
+ aggregate,
866
+ params_coverage: paramsCoverage(allows, outcomes, invalidAllowIds),
867
+ per_call: perCall,
868
+ per_node_lifecycle: lifecycle,
869
+ failures: failures.messages,
870
+ },
794
871
  failures,
795
- };
872
+ ];
873
+ }
874
+ /**
875
+ * `[seq, node]` of the FIRST entry the hash chain does not reproduce at — position only.
876
+ *
877
+ * `AuditLog.verify` stays the authority on WHETHER the chain is broken and on the message this
878
+ * module reports; this walk exists so the structured twin of that message can say WHERE, which
879
+ * the message's own text does not expose in a parseable form. Mirrors `AuditLog.verify`'s walk
880
+ * exactly (same seq/prev_hash/hash order). `[null, null]` when nothing entry-local is wrong — a
881
+ * consistently re-hashed ledger fails against the signed anchor, not here, and that failure is
882
+ * chain-level.
883
+ */
884
+ function integrityPosition(entries) {
885
+ let prev = audit_js_1.GENESIS;
886
+ for (let i = 0; i < entries.length; i++) {
887
+ const e = entries[i];
888
+ const payload = {};
889
+ for (const [k, v] of Object.entries(e)) {
890
+ if (k !== "hash")
891
+ payload[k] = v;
892
+ }
893
+ let broken;
894
+ try {
895
+ broken =
896
+ orNull(e["seq"]) !== i ||
897
+ orNull(payload["prev_hash"]) !== prev ||
898
+ (0, audit_js_1.hashEntry)(prev, payload) !== orNull(e["hash"]);
899
+ }
900
+ catch {
901
+ // An unhashable payload is itself the break, at this entry.
902
+ return [orNull(e["seq"]), orNull(e["node"])];
903
+ }
904
+ if (broken)
905
+ return [orNull(e["seq"]), orNull(e["node"])];
906
+ prev = orNull(e["hash"]);
907
+ }
908
+ return [null, null];
796
909
  }
797
910
  function verifyBundle(bundle, signer = null, options = {}) {
798
911
  const entries = bundle.entries ?? [];
@@ -808,38 +921,41 @@ function verifyBundle(bundle, signer = null, options = {}) {
808
921
  root: false,
809
922
  expected_anchor: "not checked",
810
923
  };
811
- const failures = [];
924
+ const log = new FailureLog();
812
925
  // (0) version: the bundle must declare a schema version this build understands, and — when
813
926
  // an anchor is present — the anchor must be anchoring THAT version, not a different one.
814
927
  const bundleV = (0, canonical_js_1.toPlain)(bundle.v);
815
928
  let versionOk = typeof bundleV === "number" && exports.SUPPORTED_BUNDLE_VERSIONS.has(bundleV);
816
929
  if (!versionOk) {
817
930
  const supported = Array.from(exports.SUPPORTED_BUNDLE_VERSIONS).sort((a, b) => a - b);
818
- failures.push(`unsupported_version: bundle v=${pyRepr(bundleV)} not in [${supported.join(", ")}]`);
931
+ log.add("unsupported_version", `unsupported_version: bundle v=${pyRepr(bundleV)} not in [${supported.join(", ")}]`);
819
932
  }
820
933
  const anchorV = (0, canonical_js_1.toPlain)(anchor["v"]);
821
934
  if (anchorPresent && anchorV !== bundleV) {
822
935
  versionOk = false;
823
- failures.push(`anchor_version_mismatch: anchor v=${pyRepr(anchorV)} != bundle v=${pyRepr(bundleV)}`);
936
+ log.add("anchor_version_mismatch", `anchor_version_mismatch: anchor v=${pyRepr(anchorV)} != bundle v=${pyRepr(bundleV)}`);
824
937
  }
825
938
  // (0a) exactly one root: a rootless bundle (or one splicing in a second root) would otherwise
826
939
  // sail through monotonicity/containment trivially — there is nothing to anchor those checks to.
827
940
  const rootEvents = entries.filter((e) => (0, canonical_js_1.toPlain)(e["event"]) === "root");
828
941
  checks.root = rootEvents.length === 1;
829
942
  if (!checks.root) {
830
- failures.push(`missing_root: bundle has ${rootEvents.length} root event(s), expected exactly 1`);
943
+ log.add("missing_root", `missing_root: bundle has ${rootEvents.length} root event(s), expected exactly 1`);
831
944
  }
832
945
  const rootEntry = rootEvents.length === 1 ? rootEvents[0] : undefined;
833
946
  // 0.9.0: a chain is created at ONE schema version and never mixes (spec section 9) — the root
834
947
  // entry's v must equal the bundle's declared v, and no OTHER entry may carry a different v.
835
948
  if (rootEntry !== undefined && (0, canonical_js_1.toPlain)(rootEntry["v"]) !== bundleV) {
836
949
  versionOk = false;
837
- failures.push(`root_version_mismatch: root v=${pyRepr((0, canonical_js_1.toPlain)(rootEntry["v"]))} != bundle v=${pyRepr(bundleV)}`);
950
+ log.add("root_version_mismatch", `root_version_mismatch: root v=${pyRepr((0, canonical_js_1.toPlain)(rootEntry["v"]))} != bundle v=${pyRepr(bundleV)}`, { seq: orNull(rootEntry["seq"]), node: orNull(rootEntry["node"]) });
838
951
  }
839
- const mixed = Array.from(new Set(entries.map((e) => (0, canonical_js_1.toPlain)(e["v"])).filter((v) => v !== bundleV))).sort((a, b) => (typeof a === "number" && typeof b === "number" ? a - b : String(a).localeCompare(String(b))));
952
+ const mixedEntries = entries.filter((e) => (0, canonical_js_1.toPlain)(e["v"]) !== bundleV);
953
+ const mixed = Array.from(new Set(mixedEntries.map((e) => (0, canonical_js_1.toPlain)(e["v"])))).sort((a, b) => (typeof a === "number" && typeof b === "number" ? a - b : String(a).localeCompare(String(b))));
840
954
  if (mixed.length > 0) {
841
955
  versionOk = false;
842
- failures.push(`mixed_entry_versions: entries declare v in [${mixed.map((v) => pyRepr(v)).join(", ")}], bundle v=${pyRepr(bundleV)}`);
956
+ // One aggregate message over every offending entry (unchanged); the twin is positioned on
957
+ // the first of them, which is where a reader looks.
958
+ log.add("mixed_entry_versions", `mixed_entry_versions: entries declare v in [${mixed.map((v) => pyRepr(v)).join(", ")}], bundle v=${pyRepr(bundleV)}`, { seq: orNull(mixedEntries[0]["seq"]), node: orNull(mixedEntries[0]["node"]) });
843
959
  }
844
960
  checks.version = versionOk;
845
961
  // (0c) independently retained expected anchor/head: verified against the BUNDLE's actual
@@ -853,7 +969,7 @@ function verifyBundle(bundle, signer = null, options = {}) {
853
969
  const [expSeq, expHash] = expectedHead;
854
970
  if (actualSeq !== expSeq || actualHead !== expHash) {
855
971
  expectedOk = false;
856
- failures.push(`expected_head_mismatch: bundle head is (seq=${actualSeq}, hash=${actualHead}) but the ` +
972
+ log.add("expected_head_mismatch", `expected_head_mismatch: bundle head is (seq=${actualSeq}, hash=${actualHead}) but the ` +
857
973
  `independently retained expected head is (seq=${expSeq}, hash=${expHash})`);
858
974
  }
859
975
  }
@@ -864,7 +980,7 @@ function verifyBundle(bundle, signer = null, options = {}) {
864
980
  (0, canonical_js_1.toPlain)(ea["chain_id"]) !== (0, canonical_js_1.toPlain)(bundle.chain_id) ||
865
981
  (0, canonical_js_1.toPlain)(ea["v"]) !== bundleV) {
866
982
  expectedOk = false;
867
- failures.push("expected_anchor_mismatch: the bundle's actual (seq, head, chainId, v) does not match " +
983
+ log.add("expected_anchor_mismatch", "expected_anchor_mismatch: the bundle's actual (seq, head, chainId, v) does not match " +
868
984
  "the independently retained expected anchor");
869
985
  }
870
986
  }
@@ -874,32 +990,40 @@ function verifyBundle(bundle, signer = null, options = {}) {
874
990
  // must all name the SAME chain. Without this a correctly-signed, internally-consistent bundle
875
991
  // for a DIFFERENT chain could be handed to a verifier who believes it is checking this one.
876
992
  const bundleChainId = orNull(bundle.chain_id);
877
- const entriesOk = entries.every((e) => orNull(e["chain_id"]) === bundleChainId);
878
- if (!entriesOk) {
879
- failures.push(`chain_id_mismatch: an entry does not carry chain_id=${pyRepr(bundleChainId)}`);
993
+ const foreign = entries.find((e) => orNull(e["chain_id"]) !== bundleChainId);
994
+ const entriesOk = foreign === undefined;
995
+ if (foreign !== undefined) {
996
+ log.add("chain_id_mismatch", `chain_id_mismatch: an entry does not carry chain_id=${pyRepr(bundleChainId)}`, {
997
+ seq: orNull(foreign["seq"]),
998
+ node: orNull(foreign["node"]),
999
+ });
880
1000
  }
881
1001
  const anchorChainId = orNull(anchor["chain_id"]);
882
1002
  const anchorChainOk = !anchorPresent || anchorChainId === bundleChainId;
883
1003
  if (!anchorChainOk) {
884
- failures.push(`chain_id_mismatch: anchor chain_id=${pyRepr(anchorChainId)} != bundle chain_id=${pyRepr(bundleChainId)}`);
1004
+ log.add("chain_id_mismatch", `chain_id_mismatch: anchor chain_id=${pyRepr(anchorChainId)} != bundle chain_id=${pyRepr(bundleChainId)}`);
885
1005
  }
886
1006
  checks.chain_id = entriesOk && anchorChainOk;
887
1007
  // (1) integrity: the hash chain, plus the signed anchor when a key is given.
888
1008
  const [okChain, err] = audit_js_1.AuditLog.verify(entries);
889
- if (!okChain)
890
- failures.push(`integrity: ${err}`);
1009
+ if (!okChain) {
1010
+ const [badSeq, badNode] = integrityPosition(entries);
1011
+ log.add("integrity", `integrity: ${err}`, { seq: badSeq, node: badNode });
1012
+ }
891
1013
  if (signer !== null) {
892
1014
  const [okAnchor, aerr] = audit_js_1.AuditLog.verifyAnchor(entries, anchor, signer);
893
1015
  checks.anchor = okAnchor ? "verified" : "FAILED";
1016
+ // Chain-level by construction: the anchor commits to the head of the WHOLE ledger, so a
1017
+ // consistently re-hashed chain has no single offending entry to point at.
894
1018
  if (!okAnchor)
895
- failures.push(`integrity(anchor): ${aerr}`);
1019
+ log.add("integrity(anchor)", `integrity(anchor): ${aerr}`);
896
1020
  checks.integrity = okChain && okAnchor;
897
1021
  }
898
1022
  else {
899
1023
  checks.integrity = okChain;
900
1024
  }
901
- const { auth, parent, failures: afail } = nodeAuthorities(entries);
902
- failures.push(...afail);
1025
+ const { auth, parent, failures: afail, definedBy } = nodeAuthorities(entries);
1026
+ log.extend(afail);
903
1027
  // (2) monotonicity: every child ⊆ its parent.
904
1028
  let mono = true;
905
1029
  for (const [node, pid] of parent) {
@@ -910,8 +1034,9 @@ function verifyBundle(bundle, signer = null, options = {}) {
910
1034
  const extra = Array.from(child.scopes).filter((s) => !p.scopes.has(s));
911
1035
  if (!child.isNarrowerThan(p) && extra.length > 0) {
912
1036
  mono = false;
913
- failures.push(`monotonicity: ${node} not ⊆ parent ${pid} (child scopes ` +
914
- `[${extra.sort(canonical_js_1.compareCodePoints).map((s) => `'${s}'`).join(", ")}] not held by parent)`);
1037
+ const spawnE = definedBy.get(node);
1038
+ log.add("monotonicity", `monotonicity: ${node} not ⊆ parent ${pid} (child scopes ` +
1039
+ `[${extra.sort(canonical_js_1.compareCodePoints).map((s) => `'${s}'`).join(", ")}] not held by parent)`, { seq: spawnE === undefined ? null : orNull(spawnE["seq"]), node });
915
1040
  }
916
1041
  }
917
1042
  checks.monotonicity = mono && afail.length === 0;
@@ -928,19 +1053,25 @@ function verifyBundle(bundle, signer = null, options = {}) {
928
1053
  const a = auth.get(node);
929
1054
  if (a === undefined) {
930
1055
  contained = false;
931
- failures.push(`containment: allow on unknown node ${node}`);
1056
+ log.add("containment", `containment: allow on unknown node ${node}`, {
1057
+ seq: orNull(e["seq"]),
1058
+ node: orNull(e["node"]),
1059
+ callId: orNull(e["call_id"]),
1060
+ });
932
1061
  continue;
933
1062
  }
934
1063
  if (!a.permits(scope, ctx).allowed) {
935
1064
  contained = false;
936
- failures.push(`containment: allow of '${scope}' on ${node} outside its authority ` +
937
- `[${Array.from(a.scopes).sort(canonical_js_1.compareCodePoints).map((s) => `'${s}'`).join(", ")}]`);
1065
+ log.add("containment", `containment: allow of '${scope}' on ${node} outside its authority ` +
1066
+ `[${Array.from(a.scopes).sort(canonical_js_1.compareCodePoints).map((s) => `'${s}'`).join(", ")}]`, { seq: orNull(e["seq"]), node: orNull(e["node"]), callId: orNull(e["call_id"]) });
938
1067
  }
939
1068
  }
940
1069
  checks.containment = contained;
941
- const eb = versionOk ? executionBinding(entries, bundleV) : { status: "not applicable" };
942
- if (eb.failures !== undefined)
943
- failures.push(...eb.failures);
1070
+ const [eb, ebFailures] = versionOk
1071
+ ? executionBinding(entries, bundleV)
1072
+ : [{ status: "not applicable" }, new FailureLog()];
1073
+ if (eb.failures !== undefined && eb.failures.length > 0)
1074
+ log.extend(ebFailures);
944
1075
  // "anchor" and "expected_anchor" are excluded here — both carry a tri-state status string
945
1076
  // ("not checked"/"verified"/"FAILED"), not a plain pass/fail boolean, and a failed check on
946
1077
  // either already lands its own entry in `failures`, which the `ok` computation still gates on.
@@ -950,11 +1081,12 @@ function verifyBundle(bundle, signer = null, options = {}) {
950
1081
  checks.version &&
951
1082
  checks.chain_id &&
952
1083
  checks.root &&
953
- failures.length === 0;
1084
+ log.length === 0;
954
1085
  return {
955
1086
  ok,
956
1087
  checks,
957
- failures,
1088
+ failures: log.messages,
1089
+ failure_details: log.details,
958
1090
  nodes: auth.size,
959
1091
  actions_checked: actions,
960
1092
  chain_id: orNull(bundle.chain_id),