attenu-guard 0.4.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.
@@ -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`.
@@ -217,6 +225,33 @@ function orNull(value) {
217
225
  const plain = toPlain(value);
218
226
  return plain === undefined ? null : plain;
219
227
  }
228
+ /**
229
+ * The verifier's failure list, kept in two shapes that cannot drift apart.
230
+ *
231
+ * `messages` is the string list `verifyBundle` has always returned as
232
+ * `failures`; those exact strings are a published contract, so they are never
233
+ * reworded here. `details` is the structured twin of each one, appended in the
234
+ * same call. Every failure in this module goes through `add`, so a new check
235
+ * cannot add a message without its twin — `test/bundle-vectors.test.ts` greps
236
+ * this file for a direct append to a failure list and fails on one, and asserts
237
+ * the two lists stay in step at every site.
238
+ */
239
+ class FailureLog {
240
+ messages = [];
241
+ details = [];
242
+ add(reason, detail, position = {}) {
243
+ const { seq = null, node = null, callId = null } = position;
244
+ this.messages.push(detail);
245
+ this.details.push({ reason, seq, node, call_id: callId, detail });
246
+ }
247
+ extend(other) {
248
+ this.messages.push(...other.messages);
249
+ this.details.push(...other.details);
250
+ }
251
+ get length() {
252
+ return this.messages.length;
253
+ }
254
+ }
220
255
  /**
221
256
  * `node -> Authority` and `node -> parent`, reconstructed from `root` and
222
257
  * `spawn` events alone. No engine state.
@@ -224,29 +259,40 @@ function orNull(value) {
224
259
  function nodeAuthorities(entries) {
225
260
  const auth = new Map();
226
261
  const parent = new Map();
227
- const failures = [];
262
+ const failures = new FailureLog();
263
+ const definedBy = new Map();
228
264
  for (const e of entries) {
229
265
  const ev = toPlain(e["event"]);
230
266
  const node = toPlain(e["node"]);
231
267
  if (ev === "root") {
268
+ definedBy.set(node, e);
232
269
  try {
233
270
  auth.set(node, Authority.fromWire(e["authority"] ?? null));
234
271
  }
235
272
  catch (exc) {
236
- failures.push(`root ${node}: unreadable authority (${exc.message})`);
273
+ // One of the two historical messages that name a node before their colon rather than a
274
+ // reason token, so the reason is stated here instead of parsed out of the string.
275
+ failures.add("unreadable_authority", `root ${node}: unreadable authority (${exc.message})`, {
276
+ seq: orNull(e["seq"]),
277
+ node: orNull(e["node"]),
278
+ });
237
279
  }
238
280
  }
239
281
  else if (ev === "spawn") {
282
+ definedBy.set(node, e);
240
283
  parent.set(node, toPlain(e["parent"]) ?? null);
241
284
  try {
242
285
  auth.set(node, Authority.fromWire(e["granted"] ?? null));
243
286
  }
244
287
  catch (exc) {
245
- failures.push(`spawn ${node}: unreadable granted (${exc.message})`);
288
+ failures.add("unreadable_granted", `spawn ${node}: unreadable granted (${exc.message})`, {
289
+ seq: orNull(e["seq"]),
290
+ node: orNull(e["node"]),
291
+ });
246
292
  }
247
293
  }
248
294
  }
249
- return { auth, parent, failures };
295
+ return { auth, parent, failures, definedBy };
250
296
  }
251
297
  /**
252
298
  * A view of the chain from the bundle: each node with its agent, task,
@@ -585,24 +631,31 @@ const V2_ONLY_FIELDS = [
585
631
  * invalid regardless of which field it is (merge-gate item 4/(c)).
586
632
  */
587
633
  function v2FieldLeaksOnV1(entries) {
588
- const failures = [];
634
+ const failures = new FailureLog();
589
635
  for (const e of entries) {
590
636
  const leaked = V2_ONLY_FIELDS.filter((f) => f in e).sort();
591
637
  if (leaked.length > 0) {
592
- failures.push(`v2_field_on_v1: seq=${pyRepr(toPlain(e["seq"]))} event=${pyRepr(toPlain(e["event"]))} ` +
593
- `carries v2-only field(s) ${JSON.stringify(leaked)} on a schemaVersion: 1 entry`);
638
+ failures.add("v2_field_on_v1", `v2_field_on_v1: seq=${pyRepr(toPlain(e["seq"]))} event=${pyRepr(toPlain(e["event"]))} ` +
639
+ `carries v2-only field(s) ${JSON.stringify(leaked)} on a schemaVersion: 1 entry`, { seq: orNull(e["seq"]), node: orNull(e["node"]) });
594
640
  }
595
641
  }
596
642
  return failures;
597
643
  }
644
+ /**
645
+ * `[the execution_binding report, its failures]`. The report's own `failures` key keeps its
646
+ * historical list-of-strings shape — the structured twins ride alongside it rather than inside
647
+ * it, so this sub-report's published shape is unchanged.
648
+ */
598
649
  function executionBinding(entries, bundleV) {
599
650
  if (bundleV === 1) {
600
651
  const leaked = v2FieldLeaksOnV1(entries);
601
- return leaked.length > 0 ? { status: "not applicable", failures: leaked } : { status: "not applicable" };
652
+ return leaked.length > 0
653
+ ? [{ status: "not applicable", failures: leaked.messages }, leaked]
654
+ : [{ status: "not applicable" }, new FailureLog()];
602
655
  }
603
656
  if (bundleV !== 2)
604
- return { status: "not applicable" };
605
- const failures = [];
657
+ return [{ status: "not applicable" }, new FailureLog()];
658
+ const failures = new FailureLog();
606
659
  const seenCallIds = new Map(); // callId -> [event, node, seq]
607
660
  const allows = new Map();
608
661
  const outcomes = new Map();
@@ -618,8 +671,12 @@ function executionBinding(entries, bundleV) {
618
671
  if (node !== null)
619
672
  nodes.add(node);
620
673
  const err = validateRoot(e);
621
- if (err)
622
- failures.push(`invalid_root: ${err} (seq ${pyRepr(seqForEvent)})`);
674
+ if (err) {
675
+ failures.add("invalid_root", `invalid_root: ${err} (seq ${pyRepr(seqForEvent)})`, {
676
+ seq: orNull(e["seq"]),
677
+ node: orNull(e["node"]),
678
+ });
679
+ }
623
680
  }
624
681
  else if (ev === "spawn") {
625
682
  if (node !== null)
@@ -633,8 +690,12 @@ function executionBinding(entries, bundleV) {
633
690
  for (const r of toPlain(e["revoked"]) ?? [])
634
691
  revokedNodes.add(r);
635
692
  const err = validateKill(e);
636
- if (err)
637
- failures.push(`invalid_kill: ${err} (seq ${pyRepr(seqForEvent)})`);
693
+ if (err) {
694
+ failures.add("invalid_kill", `invalid_kill: ${err} (seq ${pyRepr(seqForEvent)})`, {
695
+ seq: orNull(e["seq"]),
696
+ node: orNull(e["node"]),
697
+ });
698
+ }
638
699
  }
639
700
  if (ev === "allow" || ev === "deny") {
640
701
  const cid = toPlain(e["call_id"]);
@@ -642,8 +703,10 @@ function executionBinding(entries, bundleV) {
642
703
  if (cid !== null && cid !== undefined) {
643
704
  const prior = seenCallIds.get(cid);
644
705
  if (prior !== undefined) {
645
- failures.push(`duplicate_call_id: call_id ${cid} on seq ${pyRepr(seq)} (${ev}) already used at seq ` +
646
- `${pyRepr(prior[2])} (${prior[0]})`);
706
+ // Positioned on the SECOND sighting: the entry that re-used a call_id is the offending
707
+ // record, the first one having been legitimate when it was written.
708
+ failures.add("duplicate_call_id", `duplicate_call_id: call_id ${cid} on seq ${pyRepr(seq)} (${ev}) already used at seq ` +
709
+ `${pyRepr(prior[2])} (${prior[0]})`, { seq: orNull(e["seq"]), node: orNull(e["node"]), callId: cid });
647
710
  }
648
711
  else {
649
712
  seenCallIds.set(cid, [ev, node, seq]);
@@ -651,7 +714,11 @@ function executionBinding(entries, bundleV) {
651
714
  }
652
715
  const err = ev === "allow" ? validateAllow(e) : validateDeny(e);
653
716
  if (err) {
654
- failures.push(`invalid_${ev}: ${err} (seq ${pyRepr(seq)})`);
717
+ failures.add(`invalid_${ev}`, `invalid_${ev}: ${err} (seq ${pyRepr(seq)})`, {
718
+ seq: orNull(e["seq"]),
719
+ node: orNull(e["node"]),
720
+ callId: cid ?? null,
721
+ });
655
722
  if (ev === "allow" && cid !== null && cid !== undefined)
656
723
  invalidAllowIds.add(cid);
657
724
  continue;
@@ -664,12 +731,16 @@ function executionBinding(entries, bundleV) {
664
731
  const seq = toPlain(e["seq"]);
665
732
  const err = validateOutcome(e);
666
733
  if (err) {
667
- failures.push(`invalid_outcome: ${err} (seq ${pyRepr(seq)})`);
734
+ failures.add("invalid_outcome", `invalid_outcome: ${err} (seq ${pyRepr(seq)})`, {
735
+ seq: orNull(e["seq"]),
736
+ node: orNull(e["node"]),
737
+ callId: cid ?? null,
738
+ });
668
739
  continue;
669
740
  }
670
741
  if (cid !== null && outcomes.has(cid)) {
671
- failures.push(`duplicate_outcome: call_id ${cid} at seq ${pyRepr(seq)} (first at seq ` +
672
- `${pyRepr(toPlain(outcomes.get(cid)["seq"]))})`);
742
+ failures.add("duplicate_outcome", `duplicate_outcome: call_id ${cid} at seq ${pyRepr(seq)} (first at seq ` +
743
+ `${pyRepr(toPlain(outcomes.get(cid)["seq"]))})`, { seq: orNull(e["seq"]), node: orNull(e["node"]), callId: cid });
673
744
  continue;
674
745
  }
675
746
  if (cid !== null)
@@ -683,29 +754,32 @@ function executionBinding(entries, bundleV) {
683
754
  // its recorded content disagrees with what was authorized (spec: "parameter equality is
684
755
  // established only for calls where both hashes are present; elsewhere only identity and order
685
756
  // binding was checked" — params_mismatch is that separate concern).
757
+ // Every failure in this loop is about a PAIR, and is positioned on the `outcome` entry: the
758
+ // allow was a complete, valid record when it was written, and it is the outcome that fails to
759
+ // bind to it (or reports different arguments than were authorized).
686
760
  const boundOk = new Set();
687
761
  for (const [cid, oc] of outcomes) {
688
762
  const allowE = allows.get(cid);
689
763
  if (allowE === undefined) {
690
- failures.push(`outcome_without_allow: call_id ${cid} at seq ${pyRepr(toPlain(oc["seq"]))} has no allow in this chain`);
764
+ failures.add("outcome_without_allow", `outcome_without_allow: call_id ${cid} at seq ${pyRepr(toPlain(oc["seq"]))} has no allow in this chain`, { seq: orNull(oc["seq"]), node: orNull(oc["node"]), callId: cid });
691
765
  continue;
692
766
  }
693
767
  const nodeOk = toPlain(allowE["node"]) === toPlain(oc["node"]);
694
768
  if (!nodeOk) {
695
- failures.push(`cross_ref: call_id ${cid} allow on node ${pyRepr(toPlain(allowE["node"]))} but ` +
696
- `outcome on node ${pyRepr(toPlain(oc["node"]))}`);
769
+ failures.add("cross_ref", `cross_ref: call_id ${cid} allow on node ${pyRepr(toPlain(allowE["node"]))} but ` +
770
+ `outcome on node ${pyRepr(toPlain(oc["node"]))}`, { seq: orNull(oc["seq"]), node: orNull(oc["node"]), callId: cid });
697
771
  }
698
772
  const ocSeq = toPlain(oc["seq"]);
699
773
  const allowSeq = toPlain(allowE["seq"]);
700
774
  const orderOk = typeof ocSeq === "number" && typeof allowSeq === "number" && ocSeq > allowSeq;
701
775
  if (!orderOk) {
702
- failures.push(`outcome_before_allow: call_id ${cid} outcome seq ${pyRepr(ocSeq ?? null)} not ` +
703
- `after allow seq ${pyRepr(allowSeq ?? null)}`);
776
+ failures.add("outcome_before_allow", `outcome_before_allow: call_id ${cid} outcome seq ${pyRepr(ocSeq ?? null)} not ` +
777
+ `after allow seq ${pyRepr(allowSeq ?? null)}`, { seq: orNull(oc["seq"]), node: orNull(oc["node"]), callId: cid });
704
778
  }
705
779
  const ah = toPlain(allowE["authorized_params_hash"]);
706
780
  const ih = toPlain(oc["invoked_params_hash"]);
707
781
  if (ah !== null && ah !== undefined && ih !== null && ih !== undefined && ah !== ih) {
708
- failures.push(`params_mismatch: call_id ${cid} authorized_params_hash ${ah} != invoked_params_hash ${ih}`);
782
+ 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 });
709
783
  }
710
784
  if (nodeOk && orderOk)
711
785
  boundOk.add(cid);
@@ -775,13 +849,52 @@ function executionBinding(entries, bundleV) {
775
849
  }
776
850
  if (Object.values(perCall).some((s) => s === "unobserved"))
777
851
  escalate("incomplete");
778
- return {
779
- aggregate,
780
- params_coverage: paramsCoverage(allows, outcomes, invalidAllowIds),
781
- per_call: perCall,
782
- per_node_lifecycle: lifecycle,
852
+ return [
853
+ {
854
+ aggregate,
855
+ params_coverage: paramsCoverage(allows, outcomes, invalidAllowIds),
856
+ per_call: perCall,
857
+ per_node_lifecycle: lifecycle,
858
+ failures: failures.messages,
859
+ },
783
860
  failures,
784
- };
861
+ ];
862
+ }
863
+ /**
864
+ * `[seq, node]` of the FIRST entry the hash chain does not reproduce at — position only.
865
+ *
866
+ * `AuditLog.verify` stays the authority on WHETHER the chain is broken and on the message this
867
+ * module reports; this walk exists so the structured twin of that message can say WHERE, which
868
+ * the message's own text does not expose in a parseable form. Mirrors `AuditLog.verify`'s walk
869
+ * exactly (same seq/prev_hash/hash order). `[null, null]` when nothing entry-local is wrong — a
870
+ * consistently re-hashed ledger fails against the signed anchor, not here, and that failure is
871
+ * chain-level.
872
+ */
873
+ function integrityPosition(entries) {
874
+ let prev = GENESIS;
875
+ for (let i = 0; i < entries.length; i++) {
876
+ const e = entries[i];
877
+ const payload = {};
878
+ for (const [k, v] of Object.entries(e)) {
879
+ if (k !== "hash")
880
+ payload[k] = v;
881
+ }
882
+ let broken;
883
+ try {
884
+ broken =
885
+ orNull(e["seq"]) !== i ||
886
+ orNull(payload["prev_hash"]) !== prev ||
887
+ hashEntry(prev, payload) !== orNull(e["hash"]);
888
+ }
889
+ catch {
890
+ // An unhashable payload is itself the break, at this entry.
891
+ return [orNull(e["seq"]), orNull(e["node"])];
892
+ }
893
+ if (broken)
894
+ return [orNull(e["seq"]), orNull(e["node"])];
895
+ prev = orNull(e["hash"]);
896
+ }
897
+ return [null, null];
785
898
  }
786
899
  export function verifyBundle(bundle, signer = null, options = {}) {
787
900
  const entries = bundle.entries ?? [];
@@ -797,38 +910,41 @@ export function verifyBundle(bundle, signer = null, options = {}) {
797
910
  root: false,
798
911
  expected_anchor: "not checked",
799
912
  };
800
- const failures = [];
913
+ const log = new FailureLog();
801
914
  // (0) version: the bundle must declare a schema version this build understands, and — when
802
915
  // an anchor is present — the anchor must be anchoring THAT version, not a different one.
803
916
  const bundleV = toPlain(bundle.v);
804
917
  let versionOk = typeof bundleV === "number" && SUPPORTED_BUNDLE_VERSIONS.has(bundleV);
805
918
  if (!versionOk) {
806
919
  const supported = Array.from(SUPPORTED_BUNDLE_VERSIONS).sort((a, b) => a - b);
807
- failures.push(`unsupported_version: bundle v=${pyRepr(bundleV)} not in [${supported.join(", ")}]`);
920
+ log.add("unsupported_version", `unsupported_version: bundle v=${pyRepr(bundleV)} not in [${supported.join(", ")}]`);
808
921
  }
809
922
  const anchorV = toPlain(anchor["v"]);
810
923
  if (anchorPresent && anchorV !== bundleV) {
811
924
  versionOk = false;
812
- failures.push(`anchor_version_mismatch: anchor v=${pyRepr(anchorV)} != bundle v=${pyRepr(bundleV)}`);
925
+ log.add("anchor_version_mismatch", `anchor_version_mismatch: anchor v=${pyRepr(anchorV)} != bundle v=${pyRepr(bundleV)}`);
813
926
  }
814
927
  // (0a) exactly one root: a rootless bundle (or one splicing in a second root) would otherwise
815
928
  // sail through monotonicity/containment trivially — there is nothing to anchor those checks to.
816
929
  const rootEvents = entries.filter((e) => toPlain(e["event"]) === "root");
817
930
  checks.root = rootEvents.length === 1;
818
931
  if (!checks.root) {
819
- failures.push(`missing_root: bundle has ${rootEvents.length} root event(s), expected exactly 1`);
932
+ log.add("missing_root", `missing_root: bundle has ${rootEvents.length} root event(s), expected exactly 1`);
820
933
  }
821
934
  const rootEntry = rootEvents.length === 1 ? rootEvents[0] : undefined;
822
935
  // 0.9.0: a chain is created at ONE schema version and never mixes (spec section 9) — the root
823
936
  // entry's v must equal the bundle's declared v, and no OTHER entry may carry a different v.
824
937
  if (rootEntry !== undefined && toPlain(rootEntry["v"]) !== bundleV) {
825
938
  versionOk = false;
826
- failures.push(`root_version_mismatch: root v=${pyRepr(toPlain(rootEntry["v"]))} != bundle v=${pyRepr(bundleV)}`);
939
+ log.add("root_version_mismatch", `root_version_mismatch: root v=${pyRepr(toPlain(rootEntry["v"]))} != bundle v=${pyRepr(bundleV)}`, { seq: orNull(rootEntry["seq"]), node: orNull(rootEntry["node"]) });
827
940
  }
828
- const mixed = Array.from(new Set(entries.map((e) => toPlain(e["v"])).filter((v) => v !== bundleV))).sort((a, b) => (typeof a === "number" && typeof b === "number" ? a - b : String(a).localeCompare(String(b))));
941
+ const mixedEntries = entries.filter((e) => toPlain(e["v"]) !== bundleV);
942
+ const mixed = Array.from(new Set(mixedEntries.map((e) => toPlain(e["v"])))).sort((a, b) => (typeof a === "number" && typeof b === "number" ? a - b : String(a).localeCompare(String(b))));
829
943
  if (mixed.length > 0) {
830
944
  versionOk = false;
831
- failures.push(`mixed_entry_versions: entries declare v in [${mixed.map((v) => pyRepr(v)).join(", ")}], bundle v=${pyRepr(bundleV)}`);
945
+ // One aggregate message over every offending entry (unchanged); the twin is positioned on
946
+ // the first of them, which is where a reader looks.
947
+ 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"]) });
832
948
  }
833
949
  checks.version = versionOk;
834
950
  // (0c) independently retained expected anchor/head: verified against the BUNDLE's actual
@@ -842,7 +958,7 @@ export function verifyBundle(bundle, signer = null, options = {}) {
842
958
  const [expSeq, expHash] = expectedHead;
843
959
  if (actualSeq !== expSeq || actualHead !== expHash) {
844
960
  expectedOk = false;
845
- failures.push(`expected_head_mismatch: bundle head is (seq=${actualSeq}, hash=${actualHead}) but the ` +
961
+ log.add("expected_head_mismatch", `expected_head_mismatch: bundle head is (seq=${actualSeq}, hash=${actualHead}) but the ` +
846
962
  `independently retained expected head is (seq=${expSeq}, hash=${expHash})`);
847
963
  }
848
964
  }
@@ -853,7 +969,7 @@ export function verifyBundle(bundle, signer = null, options = {}) {
853
969
  toPlain(ea["chain_id"]) !== toPlain(bundle.chain_id) ||
854
970
  toPlain(ea["v"]) !== bundleV) {
855
971
  expectedOk = false;
856
- failures.push("expected_anchor_mismatch: the bundle's actual (seq, head, chainId, v) does not match " +
972
+ log.add("expected_anchor_mismatch", "expected_anchor_mismatch: the bundle's actual (seq, head, chainId, v) does not match " +
857
973
  "the independently retained expected anchor");
858
974
  }
859
975
  }
@@ -863,32 +979,40 @@ export function verifyBundle(bundle, signer = null, options = {}) {
863
979
  // must all name the SAME chain. Without this a correctly-signed, internally-consistent bundle
864
980
  // for a DIFFERENT chain could be handed to a verifier who believes it is checking this one.
865
981
  const bundleChainId = orNull(bundle.chain_id);
866
- const entriesOk = entries.every((e) => orNull(e["chain_id"]) === bundleChainId);
867
- if (!entriesOk) {
868
- failures.push(`chain_id_mismatch: an entry does not carry chain_id=${pyRepr(bundleChainId)}`);
982
+ const foreign = entries.find((e) => orNull(e["chain_id"]) !== bundleChainId);
983
+ const entriesOk = foreign === undefined;
984
+ if (foreign !== undefined) {
985
+ log.add("chain_id_mismatch", `chain_id_mismatch: an entry does not carry chain_id=${pyRepr(bundleChainId)}`, {
986
+ seq: orNull(foreign["seq"]),
987
+ node: orNull(foreign["node"]),
988
+ });
869
989
  }
870
990
  const anchorChainId = orNull(anchor["chain_id"]);
871
991
  const anchorChainOk = !anchorPresent || anchorChainId === bundleChainId;
872
992
  if (!anchorChainOk) {
873
- failures.push(`chain_id_mismatch: anchor chain_id=${pyRepr(anchorChainId)} != bundle chain_id=${pyRepr(bundleChainId)}`);
993
+ log.add("chain_id_mismatch", `chain_id_mismatch: anchor chain_id=${pyRepr(anchorChainId)} != bundle chain_id=${pyRepr(bundleChainId)}`);
874
994
  }
875
995
  checks.chain_id = entriesOk && anchorChainOk;
876
996
  // (1) integrity: the hash chain, plus the signed anchor when a key is given.
877
997
  const [okChain, err] = AuditLog.verify(entries);
878
- if (!okChain)
879
- failures.push(`integrity: ${err}`);
998
+ if (!okChain) {
999
+ const [badSeq, badNode] = integrityPosition(entries);
1000
+ log.add("integrity", `integrity: ${err}`, { seq: badSeq, node: badNode });
1001
+ }
880
1002
  if (signer !== null) {
881
1003
  const [okAnchor, aerr] = AuditLog.verifyAnchor(entries, anchor, signer);
882
1004
  checks.anchor = okAnchor ? "verified" : "FAILED";
1005
+ // Chain-level by construction: the anchor commits to the head of the WHOLE ledger, so a
1006
+ // consistently re-hashed chain has no single offending entry to point at.
883
1007
  if (!okAnchor)
884
- failures.push(`integrity(anchor): ${aerr}`);
1008
+ log.add("integrity(anchor)", `integrity(anchor): ${aerr}`);
885
1009
  checks.integrity = okChain && okAnchor;
886
1010
  }
887
1011
  else {
888
1012
  checks.integrity = okChain;
889
1013
  }
890
- const { auth, parent, failures: afail } = nodeAuthorities(entries);
891
- failures.push(...afail);
1014
+ const { auth, parent, failures: afail, definedBy } = nodeAuthorities(entries);
1015
+ log.extend(afail);
892
1016
  // (2) monotonicity: every child ⊆ its parent.
893
1017
  let mono = true;
894
1018
  for (const [node, pid] of parent) {
@@ -899,8 +1023,9 @@ export function verifyBundle(bundle, signer = null, options = {}) {
899
1023
  const extra = Array.from(child.scopes).filter((s) => !p.scopes.has(s));
900
1024
  if (!child.isNarrowerThan(p) && extra.length > 0) {
901
1025
  mono = false;
902
- failures.push(`monotonicity: ${node} not ⊆ parent ${pid} (child scopes ` +
903
- `[${extra.sort(compareCodePoints).map((s) => `'${s}'`).join(", ")}] not held by parent)`);
1026
+ const spawnE = definedBy.get(node);
1027
+ log.add("monotonicity", `monotonicity: ${node} not ⊆ parent ${pid} (child scopes ` +
1028
+ `[${extra.sort(compareCodePoints).map((s) => `'${s}'`).join(", ")}] not held by parent)`, { seq: spawnE === undefined ? null : orNull(spawnE["seq"]), node });
904
1029
  }
905
1030
  }
906
1031
  checks.monotonicity = mono && afail.length === 0;
@@ -917,19 +1042,25 @@ export function verifyBundle(bundle, signer = null, options = {}) {
917
1042
  const a = auth.get(node);
918
1043
  if (a === undefined) {
919
1044
  contained = false;
920
- failures.push(`containment: allow on unknown node ${node}`);
1045
+ log.add("containment", `containment: allow on unknown node ${node}`, {
1046
+ seq: orNull(e["seq"]),
1047
+ node: orNull(e["node"]),
1048
+ callId: orNull(e["call_id"]),
1049
+ });
921
1050
  continue;
922
1051
  }
923
1052
  if (!a.permits(scope, ctx).allowed) {
924
1053
  contained = false;
925
- failures.push(`containment: allow of '${scope}' on ${node} outside its authority ` +
926
- `[${Array.from(a.scopes).sort(compareCodePoints).map((s) => `'${s}'`).join(", ")}]`);
1054
+ log.add("containment", `containment: allow of '${scope}' on ${node} outside its authority ` +
1055
+ `[${Array.from(a.scopes).sort(compareCodePoints).map((s) => `'${s}'`).join(", ")}]`, { seq: orNull(e["seq"]), node: orNull(e["node"]), callId: orNull(e["call_id"]) });
927
1056
  }
928
1057
  }
929
1058
  checks.containment = contained;
930
- const eb = versionOk ? executionBinding(entries, bundleV) : { status: "not applicable" };
931
- if (eb.failures !== undefined)
932
- failures.push(...eb.failures);
1059
+ const [eb, ebFailures] = versionOk
1060
+ ? executionBinding(entries, bundleV)
1061
+ : [{ status: "not applicable" }, new FailureLog()];
1062
+ if (eb.failures !== undefined && eb.failures.length > 0)
1063
+ log.extend(ebFailures);
933
1064
  // "anchor" and "expected_anchor" are excluded here — both carry a tri-state status string
934
1065
  // ("not checked"/"verified"/"FAILED"), not a plain pass/fail boolean, and a failed check on
935
1066
  // either already lands its own entry in `failures`, which the `ok` computation still gates on.
@@ -939,11 +1070,12 @@ export function verifyBundle(bundle, signer = null, options = {}) {
939
1070
  checks.version &&
940
1071
  checks.chain_id &&
941
1072
  checks.root &&
942
- failures.length === 0;
1073
+ log.length === 0;
943
1074
  return {
944
1075
  ok,
945
1076
  checks,
946
- failures,
1077
+ failures: log.messages,
1078
+ failure_details: log.details,
947
1079
  nodes: auth.size,
948
1080
  actions_checked: actions,
949
1081
  chain_id: orNull(bundle.chain_id),