attenu-guard 0.5.0 → 0.7.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.
@@ -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`.
@@ -36,6 +44,7 @@ const canonical_js_1 = require("./canonical.js");
36
44
  const node_crypto_1 = require("node:crypto");
37
45
  const audit_js_1 = require("./audit.js");
38
46
  const authority_js_1 = require("./authority.js");
47
+ const ceilings_js_1 = require("./ceilings.js");
39
48
  const reasons_js_1 = require("./reasons.js");
40
49
  const params_js_1 = require("./params.js");
41
50
  /**
@@ -228,36 +237,124 @@ function orNull(value) {
228
237
  const plain = (0, canonical_js_1.toPlain)(value);
229
238
  return plain === undefined ? null : plain;
230
239
  }
240
+ /**
241
+ * The verifier's failure list, kept in two shapes that cannot drift apart.
242
+ *
243
+ * `messages` is the string list `verifyBundle` has always returned as
244
+ * `failures`; those exact strings are a published contract, so they are never
245
+ * reworded here. `details` is the structured twin of each one, appended in the
246
+ * same call. Every failure in this module goes through `add`, so a new check
247
+ * cannot add a message without its twin — `test/bundle-vectors.test.ts` greps
248
+ * this file for a direct append to a failure list and fails on one, and asserts
249
+ * the two lists stay in step at every site.
250
+ */
251
+ class FailureLog {
252
+ messages = [];
253
+ details = [];
254
+ add(reason, detail, position = {}) {
255
+ const { seq = null, node = null, callId = null } = position;
256
+ this.messages.push(detail);
257
+ this.details.push({ reason, seq, node, call_id: callId, detail });
258
+ }
259
+ extend(other) {
260
+ this.messages.push(...other.messages);
261
+ this.details.push(...other.details);
262
+ }
263
+ get length() {
264
+ return this.messages.length;
265
+ }
266
+ }
231
267
  /**
232
268
  * `node -> Authority` and `node -> parent`, reconstructed from `root` and
233
269
  * `spawn` events alone. No engine state.
234
270
  */
271
+ /**
272
+ * Why `child` is not ⊆ `parent`, rendered for the monotonicity failure message.
273
+ *
274
+ * Called only once `Authority.isNarrowerThan` has already returned false, and it walks the
275
+ * dimensions in the ORDER that relation compares them — scopes, then ceilings by key, then ttl
276
+ * — so the message names the dimension that actually failed. Every dimension the relation can
277
+ * fail on has a branch here:
278
+ *
279
+ * scopes a scope the parent does not cover (wildcard-aware);
280
+ * ceilings a key the parent bounds and the child does not (child unbounded there, so MORE
281
+ * powerful), or one the child bounds more loosely than the parent;
282
+ * ttl a child that never expires under a parent that does, or one that outlives it.
283
+ *
284
+ * Reports the FIRST failing dimension: one message per unsound delegation. Byte-identical to
285
+ * the Python `evidence._monotonicity_detail`, since these strings are a published contract that
286
+ * both implementations are scored against.
287
+ */
288
+ function monotonicityDetail(child, parent) {
289
+ // Unchanged since 0.1.0, byte for byte. A scope failure always leaves this list non-empty:
290
+ // a scope literally present in the parent's set is covered by it, so anything the parent
291
+ // does not cover is also absent from that set.
292
+ if (!Array.from(child.scopes).every((s) => parent.coversScope(s))) {
293
+ const extra = Array.from(child.scopes).filter((s) => !parent.scopes.has(s));
294
+ return (`child scopes [${extra.sort(canonical_js_1.compareCodePoints).map((s) => `'${s}'`).join(", ")}] ` +
295
+ `not held by parent`);
296
+ }
297
+ const childByKey = new Map(child.ceilings.map((c) => [String(c.key), c]));
298
+ const parentKeys = parent.ceilings.map((c) => String(c.key)).sort(canonical_js_1.compareCodePoints);
299
+ for (const key of parentKeys) {
300
+ const parentCeiling = parent.ceilings.find((c) => String(c.key) === key);
301
+ const childCeiling = childByKey.get(key);
302
+ if (childCeiling === undefined) {
303
+ return `ceiling ${key} unbounded, parent holds ${(0, ceilings_js_1.describe)(parentCeiling)}`;
304
+ }
305
+ if (!parentCeiling.subsumes(childCeiling)) {
306
+ return (`ceiling ${(0, ceilings_js_1.describe)(childCeiling)} looser than parent ` +
307
+ `${(0, ceilings_js_1.describe)(parentCeiling)}`);
308
+ }
309
+ }
310
+ if (parent.ttl !== null) {
311
+ if (child.ttl === null)
312
+ return `ttl unbounded, parent ${(0, canonical_js_1.pyNumber)(parent.ttl)}`;
313
+ if (child.ttl > parent.ttl) {
314
+ return `ttl ${(0, canonical_js_1.pyNumber)(child.ttl)} > parent ${(0, canonical_js_1.pyNumber)(parent.ttl)}`;
315
+ }
316
+ }
317
+ // Only reachable if a future dimension is added to `isNarrowerThan` without a branch here;
318
+ // it exists so that such a dimension cannot fail SILENTLY.
319
+ return "child not narrower than parent";
320
+ }
235
321
  function nodeAuthorities(entries) {
236
322
  const auth = new Map();
237
323
  const parent = new Map();
238
- const failures = [];
324
+ const failures = new FailureLog();
325
+ const definedBy = new Map();
239
326
  for (const e of entries) {
240
327
  const ev = (0, canonical_js_1.toPlain)(e["event"]);
241
328
  const node = (0, canonical_js_1.toPlain)(e["node"]);
242
329
  if (ev === "root") {
330
+ definedBy.set(node, e);
243
331
  try {
244
332
  auth.set(node, authority_js_1.Authority.fromWire(e["authority"] ?? null));
245
333
  }
246
334
  catch (exc) {
247
- failures.push(`root ${node}: unreadable authority (${exc.message})`);
335
+ // One of the two historical messages that name a node before their colon rather than a
336
+ // reason token, so the reason is stated here instead of parsed out of the string.
337
+ failures.add("unreadable_authority", `root ${node}: unreadable authority (${exc.message})`, {
338
+ seq: orNull(e["seq"]),
339
+ node: orNull(e["node"]),
340
+ });
248
341
  }
249
342
  }
250
343
  else if (ev === "spawn") {
344
+ definedBy.set(node, e);
251
345
  parent.set(node, (0, canonical_js_1.toPlain)(e["parent"]) ?? null);
252
346
  try {
253
347
  auth.set(node, authority_js_1.Authority.fromWire(e["granted"] ?? null));
254
348
  }
255
349
  catch (exc) {
256
- failures.push(`spawn ${node}: unreadable granted (${exc.message})`);
350
+ failures.add("unreadable_granted", `spawn ${node}: unreadable granted (${exc.message})`, {
351
+ seq: orNull(e["seq"]),
352
+ node: orNull(e["node"]),
353
+ });
257
354
  }
258
355
  }
259
356
  }
260
- return { auth, parent, failures };
357
+ return { auth, parent, failures, definedBy };
261
358
  }
262
359
  /**
263
360
  * A view of the chain from the bundle: each node with its agent, task,
@@ -596,24 +693,31 @@ const V2_ONLY_FIELDS = [
596
693
  * invalid regardless of which field it is (merge-gate item 4/(c)).
597
694
  */
598
695
  function v2FieldLeaksOnV1(entries) {
599
- const failures = [];
696
+ const failures = new FailureLog();
600
697
  for (const e of entries) {
601
698
  const leaked = V2_ONLY_FIELDS.filter((f) => f in e).sort();
602
699
  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`);
700
+ 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"]))} ` +
701
+ `carries v2-only field(s) ${JSON.stringify(leaked)} on a schemaVersion: 1 entry`, { seq: orNull(e["seq"]), node: orNull(e["node"]) });
605
702
  }
606
703
  }
607
704
  return failures;
608
705
  }
706
+ /**
707
+ * `[the execution_binding report, its failures]`. The report's own `failures` key keeps its
708
+ * historical list-of-strings shape — the structured twins ride alongside it rather than inside
709
+ * it, so this sub-report's published shape is unchanged.
710
+ */
609
711
  function executionBinding(entries, bundleV) {
610
712
  if (bundleV === 1) {
611
713
  const leaked = v2FieldLeaksOnV1(entries);
612
- return leaked.length > 0 ? { status: "not applicable", failures: leaked } : { status: "not applicable" };
714
+ return leaked.length > 0
715
+ ? [{ status: "not applicable", failures: leaked.messages }, leaked]
716
+ : [{ status: "not applicable" }, new FailureLog()];
613
717
  }
614
718
  if (bundleV !== 2)
615
- return { status: "not applicable" };
616
- const failures = [];
719
+ return [{ status: "not applicable" }, new FailureLog()];
720
+ const failures = new FailureLog();
617
721
  const seenCallIds = new Map(); // callId -> [event, node, seq]
618
722
  const allows = new Map();
619
723
  const outcomes = new Map();
@@ -629,8 +733,12 @@ function executionBinding(entries, bundleV) {
629
733
  if (node !== null)
630
734
  nodes.add(node);
631
735
  const err = validateRoot(e);
632
- if (err)
633
- failures.push(`invalid_root: ${err} (seq ${pyRepr(seqForEvent)})`);
736
+ if (err) {
737
+ failures.add("invalid_root", `invalid_root: ${err} (seq ${pyRepr(seqForEvent)})`, {
738
+ seq: orNull(e["seq"]),
739
+ node: orNull(e["node"]),
740
+ });
741
+ }
634
742
  }
635
743
  else if (ev === "spawn") {
636
744
  if (node !== null)
@@ -644,8 +752,12 @@ function executionBinding(entries, bundleV) {
644
752
  for (const r of (0, canonical_js_1.toPlain)(e["revoked"]) ?? [])
645
753
  revokedNodes.add(r);
646
754
  const err = validateKill(e);
647
- if (err)
648
- failures.push(`invalid_kill: ${err} (seq ${pyRepr(seqForEvent)})`);
755
+ if (err) {
756
+ failures.add("invalid_kill", `invalid_kill: ${err} (seq ${pyRepr(seqForEvent)})`, {
757
+ seq: orNull(e["seq"]),
758
+ node: orNull(e["node"]),
759
+ });
760
+ }
649
761
  }
650
762
  if (ev === "allow" || ev === "deny") {
651
763
  const cid = (0, canonical_js_1.toPlain)(e["call_id"]);
@@ -653,8 +765,10 @@ function executionBinding(entries, bundleV) {
653
765
  if (cid !== null && cid !== undefined) {
654
766
  const prior = seenCallIds.get(cid);
655
767
  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]})`);
768
+ // Positioned on the SECOND sighting: the entry that re-used a call_id is the offending
769
+ // record, the first one having been legitimate when it was written.
770
+ failures.add("duplicate_call_id", `duplicate_call_id: call_id ${cid} on seq ${pyRepr(seq)} (${ev}) already used at seq ` +
771
+ `${pyRepr(prior[2])} (${prior[0]})`, { seq: orNull(e["seq"]), node: orNull(e["node"]), callId: cid });
658
772
  }
659
773
  else {
660
774
  seenCallIds.set(cid, [ev, node, seq]);
@@ -662,7 +776,11 @@ function executionBinding(entries, bundleV) {
662
776
  }
663
777
  const err = ev === "allow" ? validateAllow(e) : validateDeny(e);
664
778
  if (err) {
665
- failures.push(`invalid_${ev}: ${err} (seq ${pyRepr(seq)})`);
779
+ failures.add(`invalid_${ev}`, `invalid_${ev}: ${err} (seq ${pyRepr(seq)})`, {
780
+ seq: orNull(e["seq"]),
781
+ node: orNull(e["node"]),
782
+ callId: cid ?? null,
783
+ });
666
784
  if (ev === "allow" && cid !== null && cid !== undefined)
667
785
  invalidAllowIds.add(cid);
668
786
  continue;
@@ -675,12 +793,16 @@ function executionBinding(entries, bundleV) {
675
793
  const seq = (0, canonical_js_1.toPlain)(e["seq"]);
676
794
  const err = validateOutcome(e);
677
795
  if (err) {
678
- failures.push(`invalid_outcome: ${err} (seq ${pyRepr(seq)})`);
796
+ failures.add("invalid_outcome", `invalid_outcome: ${err} (seq ${pyRepr(seq)})`, {
797
+ seq: orNull(e["seq"]),
798
+ node: orNull(e["node"]),
799
+ callId: cid ?? null,
800
+ });
679
801
  continue;
680
802
  }
681
803
  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"]))})`);
804
+ failures.add("duplicate_outcome", `duplicate_outcome: call_id ${cid} at seq ${pyRepr(seq)} (first at seq ` +
805
+ `${pyRepr((0, canonical_js_1.toPlain)(outcomes.get(cid)["seq"]))})`, { seq: orNull(e["seq"]), node: orNull(e["node"]), callId: cid });
684
806
  continue;
685
807
  }
686
808
  if (cid !== null)
@@ -694,29 +816,32 @@ function executionBinding(entries, bundleV) {
694
816
  // its recorded content disagrees with what was authorized (spec: "parameter equality is
695
817
  // established only for calls where both hashes are present; elsewhere only identity and order
696
818
  // binding was checked" — params_mismatch is that separate concern).
819
+ // Every failure in this loop is about a PAIR, and is positioned on the `outcome` entry: the
820
+ // allow was a complete, valid record when it was written, and it is the outcome that fails to
821
+ // bind to it (or reports different arguments than were authorized).
697
822
  const boundOk = new Set();
698
823
  for (const [cid, oc] of outcomes) {
699
824
  const allowE = allows.get(cid);
700
825
  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`);
826
+ 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
827
  continue;
703
828
  }
704
829
  const nodeOk = (0, canonical_js_1.toPlain)(allowE["node"]) === (0, canonical_js_1.toPlain)(oc["node"]);
705
830
  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"]))}`);
831
+ failures.add("cross_ref", `cross_ref: call_id ${cid} allow on node ${pyRepr((0, canonical_js_1.toPlain)(allowE["node"]))} but ` +
832
+ `outcome on node ${pyRepr((0, canonical_js_1.toPlain)(oc["node"]))}`, { seq: orNull(oc["seq"]), node: orNull(oc["node"]), callId: cid });
708
833
  }
709
834
  const ocSeq = (0, canonical_js_1.toPlain)(oc["seq"]);
710
835
  const allowSeq = (0, canonical_js_1.toPlain)(allowE["seq"]);
711
836
  const orderOk = typeof ocSeq === "number" && typeof allowSeq === "number" && ocSeq > allowSeq;
712
837
  if (!orderOk) {
713
- failures.push(`outcome_before_allow: call_id ${cid} outcome seq ${pyRepr(ocSeq ?? null)} not ` +
714
- `after allow seq ${pyRepr(allowSeq ?? null)}`);
838
+ failures.add("outcome_before_allow", `outcome_before_allow: call_id ${cid} outcome seq ${pyRepr(ocSeq ?? null)} not ` +
839
+ `after allow seq ${pyRepr(allowSeq ?? null)}`, { seq: orNull(oc["seq"]), node: orNull(oc["node"]), callId: cid });
715
840
  }
716
841
  const ah = (0, canonical_js_1.toPlain)(allowE["authorized_params_hash"]);
717
842
  const ih = (0, canonical_js_1.toPlain)(oc["invoked_params_hash"]);
718
843
  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}`);
844
+ 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
845
  }
721
846
  if (nodeOk && orderOk)
722
847
  boundOk.add(cid);
@@ -786,13 +911,52 @@ function executionBinding(entries, bundleV) {
786
911
  }
787
912
  if (Object.values(perCall).some((s) => s === "unobserved"))
788
913
  escalate("incomplete");
789
- return {
790
- aggregate,
791
- params_coverage: paramsCoverage(allows, outcomes, invalidAllowIds),
792
- per_call: perCall,
793
- per_node_lifecycle: lifecycle,
914
+ return [
915
+ {
916
+ aggregate,
917
+ params_coverage: paramsCoverage(allows, outcomes, invalidAllowIds),
918
+ per_call: perCall,
919
+ per_node_lifecycle: lifecycle,
920
+ failures: failures.messages,
921
+ },
794
922
  failures,
795
- };
923
+ ];
924
+ }
925
+ /**
926
+ * `[seq, node]` of the FIRST entry the hash chain does not reproduce at — position only.
927
+ *
928
+ * `AuditLog.verify` stays the authority on WHETHER the chain is broken and on the message this
929
+ * module reports; this walk exists so the structured twin of that message can say WHERE, which
930
+ * the message's own text does not expose in a parseable form. Mirrors `AuditLog.verify`'s walk
931
+ * exactly (same seq/prev_hash/hash order). `[null, null]` when nothing entry-local is wrong — a
932
+ * consistently re-hashed ledger fails against the signed anchor, not here, and that failure is
933
+ * chain-level.
934
+ */
935
+ function integrityPosition(entries) {
936
+ let prev = audit_js_1.GENESIS;
937
+ for (let i = 0; i < entries.length; i++) {
938
+ const e = entries[i];
939
+ const payload = {};
940
+ for (const [k, v] of Object.entries(e)) {
941
+ if (k !== "hash")
942
+ payload[k] = v;
943
+ }
944
+ let broken;
945
+ try {
946
+ broken =
947
+ orNull(e["seq"]) !== i ||
948
+ orNull(payload["prev_hash"]) !== prev ||
949
+ (0, audit_js_1.hashEntry)(prev, payload) !== orNull(e["hash"]);
950
+ }
951
+ catch {
952
+ // An unhashable payload is itself the break, at this entry.
953
+ return [orNull(e["seq"]), orNull(e["node"])];
954
+ }
955
+ if (broken)
956
+ return [orNull(e["seq"]), orNull(e["node"])];
957
+ prev = orNull(e["hash"]);
958
+ }
959
+ return [null, null];
796
960
  }
797
961
  function verifyBundle(bundle, signer = null, options = {}) {
798
962
  const entries = bundle.entries ?? [];
@@ -808,38 +972,41 @@ function verifyBundle(bundle, signer = null, options = {}) {
808
972
  root: false,
809
973
  expected_anchor: "not checked",
810
974
  };
811
- const failures = [];
975
+ const log = new FailureLog();
812
976
  // (0) version: the bundle must declare a schema version this build understands, and — when
813
977
  // an anchor is present — the anchor must be anchoring THAT version, not a different one.
814
978
  const bundleV = (0, canonical_js_1.toPlain)(bundle.v);
815
979
  let versionOk = typeof bundleV === "number" && exports.SUPPORTED_BUNDLE_VERSIONS.has(bundleV);
816
980
  if (!versionOk) {
817
981
  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(", ")}]`);
982
+ log.add("unsupported_version", `unsupported_version: bundle v=${pyRepr(bundleV)} not in [${supported.join(", ")}]`);
819
983
  }
820
984
  const anchorV = (0, canonical_js_1.toPlain)(anchor["v"]);
821
985
  if (anchorPresent && anchorV !== bundleV) {
822
986
  versionOk = false;
823
- failures.push(`anchor_version_mismatch: anchor v=${pyRepr(anchorV)} != bundle v=${pyRepr(bundleV)}`);
987
+ log.add("anchor_version_mismatch", `anchor_version_mismatch: anchor v=${pyRepr(anchorV)} != bundle v=${pyRepr(bundleV)}`);
824
988
  }
825
989
  // (0a) exactly one root: a rootless bundle (or one splicing in a second root) would otherwise
826
990
  // sail through monotonicity/containment trivially — there is nothing to anchor those checks to.
827
991
  const rootEvents = entries.filter((e) => (0, canonical_js_1.toPlain)(e["event"]) === "root");
828
992
  checks.root = rootEvents.length === 1;
829
993
  if (!checks.root) {
830
- failures.push(`missing_root: bundle has ${rootEvents.length} root event(s), expected exactly 1`);
994
+ log.add("missing_root", `missing_root: bundle has ${rootEvents.length} root event(s), expected exactly 1`);
831
995
  }
832
996
  const rootEntry = rootEvents.length === 1 ? rootEvents[0] : undefined;
833
997
  // 0.9.0: a chain is created at ONE schema version and never mixes (spec section 9) — the root
834
998
  // entry's v must equal the bundle's declared v, and no OTHER entry may carry a different v.
835
999
  if (rootEntry !== undefined && (0, canonical_js_1.toPlain)(rootEntry["v"]) !== bundleV) {
836
1000
  versionOk = false;
837
- failures.push(`root_version_mismatch: root v=${pyRepr((0, canonical_js_1.toPlain)(rootEntry["v"]))} != bundle v=${pyRepr(bundleV)}`);
1001
+ 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
1002
  }
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))));
1003
+ const mixedEntries = entries.filter((e) => (0, canonical_js_1.toPlain)(e["v"]) !== bundleV);
1004
+ 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
1005
  if (mixed.length > 0) {
841
1006
  versionOk = false;
842
- failures.push(`mixed_entry_versions: entries declare v in [${mixed.map((v) => pyRepr(v)).join(", ")}], bundle v=${pyRepr(bundleV)}`);
1007
+ // One aggregate message over every offending entry (unchanged); the twin is positioned on
1008
+ // the first of them, which is where a reader looks.
1009
+ 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
1010
  }
844
1011
  checks.version = versionOk;
845
1012
  // (0c) independently retained expected anchor/head: verified against the BUNDLE's actual
@@ -853,7 +1020,7 @@ function verifyBundle(bundle, signer = null, options = {}) {
853
1020
  const [expSeq, expHash] = expectedHead;
854
1021
  if (actualSeq !== expSeq || actualHead !== expHash) {
855
1022
  expectedOk = false;
856
- failures.push(`expected_head_mismatch: bundle head is (seq=${actualSeq}, hash=${actualHead}) but the ` +
1023
+ log.add("expected_head_mismatch", `expected_head_mismatch: bundle head is (seq=${actualSeq}, hash=${actualHead}) but the ` +
857
1024
  `independently retained expected head is (seq=${expSeq}, hash=${expHash})`);
858
1025
  }
859
1026
  }
@@ -864,7 +1031,7 @@ function verifyBundle(bundle, signer = null, options = {}) {
864
1031
  (0, canonical_js_1.toPlain)(ea["chain_id"]) !== (0, canonical_js_1.toPlain)(bundle.chain_id) ||
865
1032
  (0, canonical_js_1.toPlain)(ea["v"]) !== bundleV) {
866
1033
  expectedOk = false;
867
- failures.push("expected_anchor_mismatch: the bundle's actual (seq, head, chainId, v) does not match " +
1034
+ log.add("expected_anchor_mismatch", "expected_anchor_mismatch: the bundle's actual (seq, head, chainId, v) does not match " +
868
1035
  "the independently retained expected anchor");
869
1036
  }
870
1037
  }
@@ -874,32 +1041,40 @@ function verifyBundle(bundle, signer = null, options = {}) {
874
1041
  // must all name the SAME chain. Without this a correctly-signed, internally-consistent bundle
875
1042
  // for a DIFFERENT chain could be handed to a verifier who believes it is checking this one.
876
1043
  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)}`);
1044
+ const foreign = entries.find((e) => orNull(e["chain_id"]) !== bundleChainId);
1045
+ const entriesOk = foreign === undefined;
1046
+ if (foreign !== undefined) {
1047
+ log.add("chain_id_mismatch", `chain_id_mismatch: an entry does not carry chain_id=${pyRepr(bundleChainId)}`, {
1048
+ seq: orNull(foreign["seq"]),
1049
+ node: orNull(foreign["node"]),
1050
+ });
880
1051
  }
881
1052
  const anchorChainId = orNull(anchor["chain_id"]);
882
1053
  const anchorChainOk = !anchorPresent || anchorChainId === bundleChainId;
883
1054
  if (!anchorChainOk) {
884
- failures.push(`chain_id_mismatch: anchor chain_id=${pyRepr(anchorChainId)} != bundle chain_id=${pyRepr(bundleChainId)}`);
1055
+ log.add("chain_id_mismatch", `chain_id_mismatch: anchor chain_id=${pyRepr(anchorChainId)} != bundle chain_id=${pyRepr(bundleChainId)}`);
885
1056
  }
886
1057
  checks.chain_id = entriesOk && anchorChainOk;
887
1058
  // (1) integrity: the hash chain, plus the signed anchor when a key is given.
888
1059
  const [okChain, err] = audit_js_1.AuditLog.verify(entries);
889
- if (!okChain)
890
- failures.push(`integrity: ${err}`);
1060
+ if (!okChain) {
1061
+ const [badSeq, badNode] = integrityPosition(entries);
1062
+ log.add("integrity", `integrity: ${err}`, { seq: badSeq, node: badNode });
1063
+ }
891
1064
  if (signer !== null) {
892
1065
  const [okAnchor, aerr] = audit_js_1.AuditLog.verifyAnchor(entries, anchor, signer);
893
1066
  checks.anchor = okAnchor ? "verified" : "FAILED";
1067
+ // Chain-level by construction: the anchor commits to the head of the WHOLE ledger, so a
1068
+ // consistently re-hashed chain has no single offending entry to point at.
894
1069
  if (!okAnchor)
895
- failures.push(`integrity(anchor): ${aerr}`);
1070
+ log.add("integrity(anchor)", `integrity(anchor): ${aerr}`);
896
1071
  checks.integrity = okChain && okAnchor;
897
1072
  }
898
1073
  else {
899
1074
  checks.integrity = okChain;
900
1075
  }
901
- const { auth, parent, failures: afail } = nodeAuthorities(entries);
902
- failures.push(...afail);
1076
+ const { auth, parent, failures: afail, definedBy } = nodeAuthorities(entries);
1077
+ log.extend(afail);
903
1078
  // (2) monotonicity: every child ⊆ its parent.
904
1079
  let mono = true;
905
1080
  for (const [node, pid] of parent) {
@@ -907,11 +1082,14 @@ function verifyBundle(bundle, signer = null, options = {}) {
907
1082
  continue;
908
1083
  const child = auth.get(node);
909
1084
  const p = auth.get(pid);
910
- const extra = Array.from(child.scopes).filter((s) => !p.scopes.has(s));
911
- if (!child.isNarrowerThan(p) && extra.length > 0) {
1085
+ // 0.6.x: the subsumption relation ALONE decides. This used to be gated on a literal,
1086
+ // non-wildcard-aware scope difference, which silently accepted a delegation that widened
1087
+ // only ttl or a ceiling whenever the child's scopes happened to be literally a subset of
1088
+ // the parent's — the child was more powerful and the bundle verified clean.
1089
+ if (!child.isNarrowerThan(p)) {
912
1090
  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)`);
1091
+ const spawnE = definedBy.get(node);
1092
+ log.add("monotonicity", `monotonicity: ${node} not ⊆ parent ${pid} (${monotonicityDetail(child, p)})`, { seq: spawnE === undefined ? null : orNull(spawnE["seq"]), node });
915
1093
  }
916
1094
  }
917
1095
  checks.monotonicity = mono && afail.length === 0;
@@ -928,19 +1106,25 @@ function verifyBundle(bundle, signer = null, options = {}) {
928
1106
  const a = auth.get(node);
929
1107
  if (a === undefined) {
930
1108
  contained = false;
931
- failures.push(`containment: allow on unknown node ${node}`);
1109
+ log.add("containment", `containment: allow on unknown node ${node}`, {
1110
+ seq: orNull(e["seq"]),
1111
+ node: orNull(e["node"]),
1112
+ callId: orNull(e["call_id"]),
1113
+ });
932
1114
  continue;
933
1115
  }
934
1116
  if (!a.permits(scope, ctx).allowed) {
935
1117
  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(", ")}]`);
1118
+ log.add("containment", `containment: allow of '${scope}' on ${node} outside its authority ` +
1119
+ `[${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
1120
  }
939
1121
  }
940
1122
  checks.containment = contained;
941
- const eb = versionOk ? executionBinding(entries, bundleV) : { status: "not applicable" };
942
- if (eb.failures !== undefined)
943
- failures.push(...eb.failures);
1123
+ const [eb, ebFailures] = versionOk
1124
+ ? executionBinding(entries, bundleV)
1125
+ : [{ status: "not applicable" }, new FailureLog()];
1126
+ if (eb.failures !== undefined && eb.failures.length > 0)
1127
+ log.extend(ebFailures);
944
1128
  // "anchor" and "expected_anchor" are excluded here — both carry a tri-state status string
945
1129
  // ("not checked"/"verified"/"FAILED"), not a plain pass/fail boolean, and a failed check on
946
1130
  // either already lands its own entry in `failures`, which the `ok` computation still gates on.
@@ -950,11 +1134,12 @@ function verifyBundle(bundle, signer = null, options = {}) {
950
1134
  checks.version &&
951
1135
  checks.chain_id &&
952
1136
  checks.root &&
953
- failures.length === 0;
1137
+ log.length === 0;
954
1138
  return {
955
1139
  ok,
956
1140
  checks,
957
- failures,
1141
+ failures: log.messages,
1142
+ failure_details: log.details,
958
1143
  nodes: auth.size,
959
1144
  actions_checked: actions,
960
1145
  chain_id: orNull(bundle.chain_id),