attenu-guard 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +27 -0
  3. package/dist/cjs/audit.d.ts.map +1 -1
  4. package/dist/cjs/audit.js +45 -11
  5. package/dist/cjs/audit.js.map +1 -1
  6. package/dist/cjs/authority.d.ts.map +1 -1
  7. package/dist/cjs/authority.js +15 -9
  8. package/dist/cjs/authority.js.map +1 -1
  9. package/dist/cjs/canonical.d.ts +24 -0
  10. package/dist/cjs/canonical.d.ts.map +1 -1
  11. package/dist/cjs/canonical.js +49 -0
  12. package/dist/cjs/canonical.js.map +1 -1
  13. package/dist/cjs/ceilings.d.ts +17 -0
  14. package/dist/cjs/ceilings.d.ts.map +1 -1
  15. package/dist/cjs/ceilings.js +48 -0
  16. package/dist/cjs/ceilings.js.map +1 -1
  17. package/dist/cjs/cli.d.ts +12 -3
  18. package/dist/cjs/cli.d.ts.map +1 -1
  19. package/dist/cjs/cli.js +272 -20
  20. package/dist/cjs/cli.js.map +1 -1
  21. package/dist/cjs/display.d.ts +76 -0
  22. package/dist/cjs/display.d.ts.map +1 -0
  23. package/dist/cjs/display.js +238 -0
  24. package/dist/cjs/display.js.map +1 -0
  25. package/dist/cjs/evidence.d.ts +74 -5
  26. package/dist/cjs/evidence.d.ts.map +1 -1
  27. package/dist/cjs/evidence.js +655 -243
  28. package/dist/cjs/evidence.js.map +1 -1
  29. package/dist/cjs/version.d.ts +1 -1
  30. package/dist/cjs/version.js +1 -1
  31. package/dist/esm/audit.d.ts.map +1 -1
  32. package/dist/esm/audit.js +46 -12
  33. package/dist/esm/audit.js.map +1 -1
  34. package/dist/esm/authority.d.ts.map +1 -1
  35. package/dist/esm/authority.js +15 -9
  36. package/dist/esm/authority.js.map +1 -1
  37. package/dist/esm/canonical.d.ts +24 -0
  38. package/dist/esm/canonical.d.ts.map +1 -1
  39. package/dist/esm/canonical.js +46 -0
  40. package/dist/esm/canonical.js.map +1 -1
  41. package/dist/esm/ceilings.d.ts +17 -0
  42. package/dist/esm/ceilings.d.ts.map +1 -1
  43. package/dist/esm/ceilings.js +46 -0
  44. package/dist/esm/ceilings.js.map +1 -1
  45. package/dist/esm/cli.d.ts +12 -3
  46. package/dist/esm/cli.d.ts.map +1 -1
  47. package/dist/esm/cli.js +273 -21
  48. package/dist/esm/cli.js.map +1 -1
  49. package/dist/esm/display.d.ts +76 -0
  50. package/dist/esm/display.d.ts.map +1 -0
  51. package/dist/esm/display.js +226 -0
  52. package/dist/esm/display.js.map +1 -0
  53. package/dist/esm/evidence.d.ts +74 -5
  54. package/dist/esm/evidence.d.ts.map +1 -1
  55. package/dist/esm/evidence.js +638 -229
  56. package/dist/esm/evidence.js.map +1 -1
  57. package/dist/esm/version.d.ts +1 -1
  58. package/dist/esm/version.js +1 -1
  59. package/package.json +1 -1
@@ -39,9 +39,12 @@ exports.exportBundle = exportBundle;
39
39
  exports.delegationGraph = delegationGraph;
40
40
  exports.denials = denials;
41
41
  exports.envelopeSigningInput = envelopeSigningInput;
42
+ exports.stateKey = stateKey;
42
43
  exports.envelopeSubject = envelopeSubject;
43
44
  exports.signEnvelope = signEnvelope;
45
+ exports.validateWitnessKeys = validateWitnessKeys;
44
46
  exports.verifyEnvelopes = verifyEnvelopes;
47
+ exports.integrityBreak = integrityBreak;
45
48
  exports.verifyBundle = verifyBundle;
46
49
  exports.parseBundle = parseBundle;
47
50
  const canonical_js_1 = require("./canonical.js");
@@ -49,6 +52,7 @@ const node_crypto_1 = require("node:crypto");
49
52
  const audit_js_1 = require("./audit.js");
50
53
  const authority_js_1 = require("./authority.js");
51
54
  const ceilings_js_1 = require("./ceilings.js");
55
+ const display_js_1 = require("./display.js");
52
56
  const reasons_js_1 = require("./reasons.js");
53
57
  const params_js_1 = require("./params.js");
54
58
  const wire_js_1 = require("./wire.js");
@@ -282,23 +286,41 @@ function orNull(value) {
282
286
  class FailureLog {
283
287
  messages = [];
284
288
  details = [];
289
+ /**
290
+ * A third list in step with those two: the ledger entry each failure is about, or `null` for a
291
+ * failure about no single entry. It stays out of `details`, whose member set is the published
292
+ * contract, and `entryIndices` turns it into positions in the bundle's `entries`, which
293
+ * `verifyBundle` reports as `failure_entries`. A position is exact where a seq is not: an
294
+ * entry's seq can be missing, null, a boolean or a duplicate, and its index is still its own.
295
+ */
296
+ about = [];
285
297
  add(reason, detail, position = {}) {
286
- const { seq = null, node = null, callId = null } = position;
298
+ const { seq = null, node = null, callId = null, entry = null } = position;
287
299
  this.messages.push(detail);
288
300
  this.details.push({ reason, seq, node, call_id: callId, detail });
301
+ this.about.push(entry);
289
302
  }
290
303
  extend(other) {
291
304
  this.messages.push(...other.messages);
292
305
  this.details.push(...other.details);
306
+ this.about.push(...other.about);
307
+ }
308
+ /**
309
+ * For each failure, the index in `entries` of the entry it is about, or `null`. Found by
310
+ * identity: every entry a check reports on is one of these objects.
311
+ */
312
+ entryIndices(entries) {
313
+ const at = new Map();
314
+ entries.forEach((e, i) => {
315
+ if (!at.has(e))
316
+ at.set(e, i);
317
+ });
318
+ return this.about.map((e) => (e === null ? null : (at.get(e) ?? null)));
293
319
  }
294
320
  get length() {
295
321
  return this.messages.length;
296
322
  }
297
323
  }
298
- /**
299
- * `node -> Authority` and `node -> parent`, reconstructed from `root` and
300
- * `spawn` events alone. No engine state.
301
- */
302
324
  /**
303
325
  * Why `child` is not ⊆ `parent`, rendered for the monotonicity failure message.
304
326
  *
@@ -322,7 +344,7 @@ function monotonicityDetail(child, parent) {
322
344
  // does not cover is also absent from that set.
323
345
  if (!Array.from(child.scopes).every((s) => parent.coversScope(s))) {
324
346
  const extra = Array.from(child.scopes).filter((s) => !parent.scopes.has(s));
325
- return (`child scopes [${extra.sort(canonical_js_1.compareCodePoints).map((s) => `'${s}'`).join(", ")}] ` +
347
+ return (`child scopes ${reprList(extra.sort(canonical_js_1.compareCodePoints))} ` +
326
348
  `not held by parent`);
327
349
  }
328
350
  const childByKey = new Map(child.ceilings.map((c) => [String(c.key), c]));
@@ -331,11 +353,11 @@ function monotonicityDetail(child, parent) {
331
353
  const parentCeiling = parent.ceilings.find((c) => String(c.key) === key);
332
354
  const childCeiling = childByKey.get(key);
333
355
  if (childCeiling === undefined) {
334
- return `ceiling ${key} unbounded, parent holds ${(0, ceilings_js_1.describe)(parentCeiling)}`;
356
+ return `ceiling ${(0, display_js_1.shown)((0, ceilings_js_1.wireKeyOf)(parentCeiling))} unbounded, parent holds ${(0, ceilings_js_1.describeInFinding)(parentCeiling)}`;
335
357
  }
336
358
  if (!parentCeiling.subsumes(childCeiling)) {
337
- return (`ceiling ${(0, ceilings_js_1.describe)(childCeiling)} looser than parent ` +
338
- `${(0, ceilings_js_1.describe)(parentCeiling)}`);
359
+ return (`ceiling ${(0, ceilings_js_1.describeInFinding)(childCeiling)} looser than parent ` +
360
+ `${(0, ceilings_js_1.describeInFinding)(parentCeiling)}`);
339
361
  }
340
362
  }
341
363
  if (parent.ttl !== null) {
@@ -349,6 +371,17 @@ function monotonicityDetail(child, parent) {
349
371
  // it exists so that such a dimension cannot fail SILENTLY.
350
372
  return "child not narrower than parent";
351
373
  }
374
+ /**
375
+ * `node -> Authority` and `node -> parent`, reconstructed from `root` and `spawn` events in ledger
376
+ * order. No engine state.
377
+ *
378
+ * A node id is a string, and a node is defined once, by the root or by one spawn. A root or a
379
+ * spawn whose `node` is not a string defines nothing and is reported unreadable here. A second
380
+ * definition of a node is left out of these maps, so every later check reads the first one, and
381
+ * `verifyBundle`'s monotonicity check reports it. `parent` maps a spawned node to its `parent`
382
+ * member as written, whatever its type; whether that names a node defined earlier is judged there
383
+ * too. The Python implementation's `_node_authorities`.
384
+ */
352
385
  function nodeAuthorities(entries) {
353
386
  const auth = new Map();
354
387
  const parent = new Map();
@@ -356,37 +389,58 @@ function nodeAuthorities(entries) {
356
389
  const definedBy = new Map();
357
390
  for (const e of entries) {
358
391
  const ev = (0, canonical_js_1.toPlain)(e["event"]);
392
+ if (ev !== "root" && ev !== "spawn")
393
+ continue;
359
394
  const node = (0, canonical_js_1.toPlain)(e["node"]);
395
+ const position = { seq: orNull(e["seq"]), node: orNull(e["node"]), entry: e };
396
+ if (typeof node !== "string") {
397
+ // One of the two historical messages that name a node before their colon rather than a
398
+ // reason token, so the reason is stated here instead of parsed out of the string.
399
+ if (ev === "root") {
400
+ failures.add("unreadable_authority", `root ${(0, display_js_1.shown)(e["node"])}: unreadable authority (node is not a string)`, position);
401
+ }
402
+ else {
403
+ failures.add("unreadable_granted", `spawn ${(0, display_js_1.shown)(e["node"])}: unreadable granted (node is not a string)`, position);
404
+ }
405
+ continue;
406
+ }
407
+ if (definedBy.has(node))
408
+ continue; // defined twice: verifyBundle reports it
409
+ definedBy.set(node, e);
360
410
  if (ev === "root") {
361
- definedBy.set(node, e);
362
411
  try {
363
412
  auth.set(node, authority_js_1.Authority.fromWire(e["authority"] ?? null));
364
413
  }
365
414
  catch (exc) {
366
- // One of the two historical messages that name a node before their colon rather than a
367
- // reason token, so the reason is stated here instead of parsed out of the string.
368
- failures.add("unreadable_authority", `root ${node}: unreadable authority (${exc.message})`, {
369
- seq: orNull(e["seq"]),
370
- node: orNull(e["node"]),
371
- });
415
+ failures.add("unreadable_authority", `root ${(0, display_js_1.shown)(e["node"])}: unreadable authority (${exc.message})`, position);
372
416
  }
373
417
  }
374
- else if (ev === "spawn") {
375
- definedBy.set(node, e);
376
- parent.set(node, (0, canonical_js_1.toPlain)(e["parent"]) ?? null);
418
+ else {
419
+ parent.set(node, orNull(e["parent"]));
377
420
  try {
378
421
  auth.set(node, authority_js_1.Authority.fromWire(e["granted"] ?? null));
379
422
  }
380
423
  catch (exc) {
381
- failures.add("unreadable_granted", `spawn ${node}: unreadable granted (${exc.message})`, {
382
- seq: orNull(e["seq"]),
383
- node: orNull(e["node"]),
384
- });
424
+ failures.add("unreadable_granted", `spawn ${(0, display_js_1.shown)(e["node"])}: unreadable granted (${exc.message})`, position);
385
425
  }
386
426
  }
387
427
  }
388
428
  return { auth, parent, failures, definedBy };
389
429
  }
430
+ /**
431
+ * Record the nodes a `kill` entry revokes, node -> that kill's own `seq`; the first kill stands.
432
+ * Only string ids in a list count: anything else names no node. The Python implementation's
433
+ * `_note_revoked`.
434
+ */
435
+ function noteRevoked(entry, revokedAt) {
436
+ const revoked = entry["revoked"];
437
+ if (!Array.isArray(revoked))
438
+ return;
439
+ for (const node of revoked) {
440
+ if (typeof node === "string" && !revokedAt.has(node))
441
+ revokedAt.set(node, entry["seq"]);
442
+ }
443
+ }
390
444
  /**
391
445
  * A view of the chain from the bundle: each node with its agent, task,
392
446
  * authority, parent and per-node action counts — what a reviewer or a UI
@@ -398,10 +452,15 @@ function delegationGraph(bundle) {
398
452
  const meta = {};
399
453
  for (const e of entries) {
400
454
  const ev = (0, canonical_js_1.toPlain)(e["event"]);
401
- const n = (0, canonical_js_1.toPlain)(e["node"]);
455
+ const raw = (0, canonical_js_1.toPlain)(e["node"]);
456
+ if (typeof raw !== "string" && ev !== "kill")
457
+ continue; // a node id is a string; anything else names no node
458
+ const n = raw;
402
459
  if (ev === "root" || ev === "spawn") {
460
+ if (Object.hasOwn(meta, n))
461
+ continue; // defined once: the first definition stands
403
462
  const a = auth.get(n);
404
- meta[n] = {
463
+ setOwn(meta, n, {
405
464
  agent: orNull(e["agent"]),
406
465
  task: orNull(e["task"]),
407
466
  parent: orNull(e["parent"]),
@@ -411,30 +470,32 @@ function delegationGraph(bundle) {
411
470
  revoked: false,
412
471
  complete: false,
413
472
  denials_by_disposition: {},
414
- };
473
+ });
415
474
  }
416
- else if (ev === "allow" && meta[n]) {
475
+ else if (ev === "allow" && Object.hasOwn(meta, n)) {
417
476
  meta[n].allows += 1;
418
477
  }
419
- else if (ev === "deny" && meta[n]) {
478
+ else if (ev === "deny" && Object.hasOwn(meta, n)) {
420
479
  meta[n].denies += 1;
421
480
  // A deny without a disposition is named by its reason.
422
481
  const d = ((0, canonical_js_1.toPlain)(e["disposition"]) ?? (0, canonical_js_1.toPlain)(e["reason"]) ?? "unstated");
423
482
  meta[n].denials_by_disposition[d] = (meta[n].denials_by_disposition[d] ?? 0) + 1;
424
483
  }
425
- else if (ev === "done" && meta[n]) {
484
+ else if (ev === "done" && Object.hasOwn(meta, n)) {
426
485
  meta[n].complete = true;
427
486
  }
428
487
  else if (ev === "kill") {
429
- for (const r of (0, canonical_js_1.toPlain)(e["revoked"]) ?? []) {
430
- if (meta[r])
488
+ const revoked = new Map();
489
+ noteRevoked(e, revoked);
490
+ for (const r of revoked.keys()) {
491
+ if (Object.hasOwn(meta, r))
431
492
  meta[r].revoked = true;
432
493
  }
433
494
  }
434
495
  }
435
496
  const edges = [];
436
497
  for (const [child, p] of parent) {
437
- if (p)
498
+ if (typeof p === "string" && p !== "")
438
499
  edges.push({ parent: p, child });
439
500
  }
440
501
  return { chain_id: orNull(bundle.chain_id), nodes: meta, edges };
@@ -502,37 +563,9 @@ function denials(bundle) {
502
563
  }
503
564
  return Array.from(rows.values()).sort((a, b) => a.first_seq - b.first_seq);
504
565
  }
505
- /** Python-style repr for the small set of value types these failure messages carry. */
506
- function pyRepr(value) {
507
- if (value === null)
508
- return "None";
509
- if (typeof value === "boolean")
510
- return value ? "True" : "False";
511
- if (typeof value === "number")
512
- return String(value);
513
- if (typeof value === "string")
514
- return `'${value}'`;
515
- // Containers reach here only on hostile input — a subject member that is a list or an object
516
- // where the contract wants a scalar. Rendered the way Python's repr renders them, because the
517
- // two implementations report the same failure strings and a JSON spelling would not match.
518
- if (Array.isArray(value))
519
- return `[${value.map((v) => pyRepr(v)).join(", ")}]`;
520
- if (typeof value === "object") {
521
- const body = Object.entries(value)
522
- .map(([k, v]) => `${pyRepr(k)}: ${pyRepr(v)}`)
523
- .join(", ");
524
- return `{${body}}`;
525
- }
526
- return JSON.stringify(value);
527
- }
528
- /**
529
- * Python's `str()` where a failure string interpolates a value bare (`{x}`) rather than through
530
- * `repr` (`{x!r}`) — the same rendering for every type except a string, which `str` leaves
531
- * unquoted. Both implementations report the same failure bytes, so the distinction is load-bearing.
532
- */
533
- function pyStr(value) {
534
- return typeof value === "string" ? value : pyRepr(value);
535
- }
566
+ // `pyRepr` and `pyStr` (display.ts) are Python's `repr` and `str` for the values these failure
567
+ // messages carry. Both implementations report the same failure bytes, so the distinction between
568
+ // `{x}` and `{x!r}` is load-bearing, and `pyRepr` escapes a string exactly as `repr` does.
536
569
  // =============================================================================================
537
570
  // Observer envelopes (envelope v1) — the TypeScript half of `attenu_guard.evidence`'s.
538
571
  //
@@ -587,7 +620,9 @@ exports.ENVELOPE_ALG = "EdDSA";
587
620
  /**
588
621
  * A verifying envelope's state. It says where the signature came from and NOTHING about
589
622
  * authority — the witness is whoever holds the key `witness.kid` names, which nothing in the
590
- * envelope makes the delegation parent.
623
+ * envelope makes the delegation parent. The signature covers the entry's hash and chain position
624
+ * plus what the witness-key holder observed; it does not attest that the action was permitted.
625
+ * Monotonicity and containment answer that, from the ledger.
591
626
  */
592
627
  exports.WITNESS_SIGNED = "witness-signed";
593
628
  /**
@@ -620,15 +655,25 @@ function envelopeSigningInput(envelope) {
620
655
  return (0, canonical_js_1.canonicalBytes)(body);
621
656
  }
622
657
  /**
623
- * seq -> the entry's hash RECOMPUTED from the bundle, never read off the entry.
658
+ * Where `states` and `results` file an entry: its seq when it has one, as the integer when it is
659
+ * integral (`intOr`: `1.0` is filed as 1), and its index when it has none. The Python
660
+ * implementation's `_state_key`, rendered as a record key. It is a report key, never a lookup: an
661
+ * envelope finds the entry it covers through `subjectIndex`.
662
+ */
663
+ function stateKey(e, index) {
664
+ return String("seq" in e ? orNull((0, canonical_js_1.intOr)(e["seq"])) : index);
665
+ }
666
+ /**
667
+ * Each entry's hash RECOMPUTED from the bundle, by index, never read off the entry.
624
668
  *
625
669
  * `entry_hash` in a subject is checked against this. The walk mirrors `AuditLog.verify`, so an
626
670
  * entry whose stored `hash` was replaced does not get to supply the value it is compared against.
671
+ * By index, not by seq: an entry's seq can be missing, another entry's, or not an integer.
627
672
  */
628
673
  function recomputedHashes(entries) {
629
- const out = new Map();
674
+ const out = [];
630
675
  let prev = audit_js_1.GENESIS;
631
- entries.forEach((e, i) => {
676
+ for (const e of entries) {
632
677
  const payload = {};
633
678
  for (const [k, v] of Object.entries(e))
634
679
  if (k !== "hash")
@@ -641,20 +686,134 @@ function recomputedHashes(entries) {
641
686
  // An unhashable payload has no recomputable hash; that IS the break, at this entry.
642
687
  computed = null;
643
688
  }
644
- out.set(orNull(e["seq"]) ?? i, computed);
689
+ out.push(computed);
645
690
  prev = computed ?? audit_js_1.GENESIS;
691
+ }
692
+ return out;
693
+ }
694
+ /**
695
+ * subject.seq -> the index of the entry an envelope naming that seq covers.
696
+ *
697
+ * An entry is keyed by its own seq when that is an integral number and not a boolean, as the
698
+ * schema's integer type defines (`integral`: `1.0` is 1), and by its index when it has no seq
699
+ * member at all. An entry whose seq is a boolean, a string, null, or a fractional number is keyed
700
+ * by nothing, so no envelope covers it: in Python, keyed by the raw value, `"seq": true` took the
701
+ * envelope written for seq 1, and here a null seq was keyed by its index. Where two entries share
702
+ * a key the later one is covered, as it always was. The Python implementation's `_subject_index`.
703
+ */
704
+ function subjectIndex(entries) {
705
+ const keyed = new Map();
706
+ entries.forEach((e, i) => {
707
+ if (!("seq" in e)) {
708
+ keyed.set(i, i);
709
+ return;
710
+ }
711
+ const seq = (0, canonical_js_1.integral)(e["seq"]);
712
+ if (seq !== null)
713
+ keyed.set(seq, i);
646
714
  });
715
+ return keyed;
716
+ }
717
+ /**
718
+ * Two `seq` or `v` values are the same: equal integers when either is integral (`integral`), so
719
+ * `1.0` is 1; otherwise the same value of the same type, so `true` is not 1 and `"1"` is not 1. An
720
+ * absent value is null, as Python's `dict.get` reads it. The Python implementation's
721
+ * `_same_number`.
722
+ */
723
+ function sameNumber(a, b) {
724
+ const ia = (0, canonical_js_1.integral)(a);
725
+ const ib = (0, canonical_js_1.integral)(b);
726
+ if (ia !== null || ib !== null)
727
+ return ia === ib;
728
+ const pa = orNull(a);
729
+ const pb = orNull(b);
730
+ return pyTypeOf(pa) === pyTypeOf(pb) && pyEquals(pa, pb);
731
+ }
732
+ /** The Python type of a parsed JSON value, which tells a boolean from a number as `type()` does. */
733
+ function pyTypeOf(value) {
734
+ if (value === null)
735
+ return "NoneType";
736
+ if (Array.isArray(value))
737
+ return "list";
738
+ if (typeof value === "number")
739
+ return Number.isInteger(value) ? "int" : "float";
740
+ return typeof value === "object" ? "dict" : typeof value;
741
+ }
742
+ /**
743
+ * Python's `==` on two parsed JSON values: numbers and booleans by value (`True == 1`), strings by
744
+ * text, lists and objects member by member.
745
+ */
746
+ function pyEquals(a, b) {
747
+ const x = typeof a === "boolean" ? Number(a) : a;
748
+ const y = typeof b === "boolean" ? Number(b) : b;
749
+ if (typeof x !== "object" || typeof y !== "object" || x === null || y === null)
750
+ return x === y;
751
+ if (Array.isArray(x) || Array.isArray(y)) {
752
+ return Array.isArray(x) && Array.isArray(y) && x.length === y.length && x.every((v, i) => pyEquals(v, y[i]));
753
+ }
754
+ const keys = Object.keys(x);
755
+ return (keys.length === Object.keys(y).length && keys.every((k) => Object.hasOwn(y, k) && pyEquals(x[k], y[k])));
756
+ }
757
+ /** Python's `{value!r}` of an integral-or-not `seq` or `v`: the integer when it is integral (`intOr`). */
758
+ function reprIntOr(value) {
759
+ return (0, display_js_1.pyRepr)(((0, canonical_js_1.intOr)(value) ?? null));
760
+ }
761
+ /**
762
+ * The distinct versions of `values`, as a Python set holds them: two that Python's `==` calls equal
763
+ * are one (`True` and 1 included), and the first of them stays. A list or an object has no place
764
+ * in a Python set (Python raises there), and each is kept on its own.
765
+ */
766
+ function pySetOf(values) {
767
+ const seen = new Set();
768
+ const out = [];
769
+ for (const value of values) {
770
+ const plain = (0, canonical_js_1.toPlain)(value);
771
+ const key = plain === null
772
+ ? "None"
773
+ : typeof plain === "boolean" || typeof plain === "number"
774
+ ? `n:${Number(plain) === 0 ? 0 : Number(plain)}`
775
+ : typeof plain === "string"
776
+ ? `s:${plain}`
777
+ : null;
778
+ if (key !== null) {
779
+ if (seen.has(key))
780
+ continue;
781
+ seen.add(key);
782
+ }
783
+ out.push(value);
784
+ }
647
785
  return out;
648
786
  }
787
+ /**
788
+ * The order `mixed_entry_versions` lists versions in: numbers in numeric order, and anything else
789
+ * after them by its Python `repr`, so a boolean beside a string still sorts. The Python
790
+ * implementation's `_version_order`.
791
+ */
792
+ function compareVersions(a, b) {
793
+ const numberOf = (v) => v instanceof canonical_js_1.RawNumber ? v.value : typeof v === "number" ? v : null;
794
+ const na = numberOf(a);
795
+ const nb = numberOf(b);
796
+ if (na !== null && nb !== null)
797
+ return na - nb;
798
+ if (na !== null)
799
+ return -1;
800
+ if (nb !== null)
801
+ return 1;
802
+ return (0, canonical_js_1.compareCodePoints)((0, display_js_1.pyRepr)(a), (0, display_js_1.pyRepr)(b));
803
+ }
649
804
  /**
650
805
  * The v1 subject for the entry at `seq`, recomputed from the ledger.
651
806
  *
652
- * Throws when `seq` names no entry, or names one whose `event` v1 defines no subject for.
807
+ * `seq` finds its entry the way a verifier finds it (`subjectIndex`), so the subject is the one the
808
+ * verifier will check it against. Throws when `seq` names no entry, or names one whose `event` v1
809
+ * defines no subject for.
653
810
  */
654
811
  function envelopeSubject(entries, seq) {
655
- const entry = entries.find((e) => (0, canonical_js_1.toPlain)(e["seq"]) === seq);
656
- if (entry === undefined)
812
+ const n = (0, canonical_js_1.integral)(seq);
813
+ const at = n === null ? undefined : subjectIndex(entries).get(n);
814
+ if (n === null || at === undefined)
657
815
  throw new Error(`no entry at seq ${seq}`);
816
+ const entry = entries[at];
658
817
  const event = (0, canonical_js_1.toPlain)(entry["event"]);
659
818
  if (!exports.ENVELOPE_SUBJECT_MEMBERS.has(event)) {
660
819
  throw new Error(`envelope v${exports.ENVELOPE_VERSION} defines no subject for event '${event}'`);
@@ -662,8 +821,8 @@ function envelopeSubject(entries, seq) {
662
821
  const subject = {
663
822
  chain_id: orNull(entry["chain_id"]),
664
823
  node: orNull(entry["node"]),
665
- seq,
666
- entry_hash: recomputedHashes(entries).get(seq) ?? null,
824
+ seq: n,
825
+ entry_hash: recomputedHashes(entries)[at] ?? null,
667
826
  event,
668
827
  };
669
828
  if (event === "allow")
@@ -706,21 +865,21 @@ function signEnvelope(entries, seq, seed, kid, observed) {
706
865
  function witnessPublicKey(kid, value) {
707
866
  if (typeof value === "string") {
708
867
  if (value.length !== 64) {
709
- throw new Error(`witness key '${kid}': public_key_hex must be 64 hex characters (a 32-byte Ed25519 key), ` +
868
+ throw new Error(`witness key ${(0, display_js_1.pyStrRepr)(kid)}: public_key_hex must be 64 hex characters (a 32-byte Ed25519 key), ` +
710
869
  `got ${value.length}`);
711
870
  }
712
871
  if (!/^[0-9a-fA-F]{64}$/.test(value)) {
713
- throw new Error(`witness key '${kid}': public_key_hex is not hexadecimal`);
872
+ throw new Error(`witness key ${(0, display_js_1.pyStrRepr)(kid)}: public_key_hex is not hexadecimal`);
714
873
  }
715
874
  return Buffer.from(value, "hex");
716
875
  }
717
876
  if (value instanceof Uint8Array) {
718
877
  if (value.length !== 32) {
719
- throw new Error(`witness key '${kid}': an Ed25519 public key is 32 bytes, got ${value.length}`);
878
+ throw new Error(`witness key ${(0, display_js_1.pyStrRepr)(kid)}: an Ed25519 public key is 32 bytes, got ${value.length}`);
720
879
  }
721
880
  return Buffer.from(value);
722
881
  }
723
- throw new Error(`witness key '${kid}': expected 64 hex characters or 32 bytes`);
882
+ throw new Error(`witness key ${(0, display_js_1.pyStrRepr)(kid)}: expected 64 hex characters or 32 bytes`);
724
883
  }
725
884
  /**
726
885
  * kid -> `[alg, raw public key]`, from the vector file's own `witness_keys` shape or from a plain
@@ -735,27 +894,154 @@ function witnessPublicKey(kid, value) {
735
894
  * reported to the caller rather than folded into a finding about the bundle. v1 defines Ed25519
736
895
  * and no other algorithm, so a row declaring anything else is refused too.
737
896
  */
738
- function trustedWitnesses(witnessKeys) {
897
+ function trustedWitnesses(witnessKeys, now) {
739
898
  const trusted = new Map();
899
+ const expired = new Map();
900
+ const seen = new Set();
740
901
  if (witnessKeys === null || witnessKeys === undefined)
741
- return trusted;
902
+ return { trusted, expired };
742
903
  const rows = Array.isArray(witnessKeys)
743
904
  ? witnessKeys.map((k) => [isRecordLike(k) ? k["kid"] : undefined, k])
744
905
  : Object.entries(witnessKeys);
745
906
  for (const [kid, value] of rows) {
746
907
  if (typeof kid !== "string")
747
908
  throw new Error("witness key kid must be a string");
909
+ // One row per kid. The later row used to win, so a row added to expire a key could leave it
910
+ // trusted, and an expired row beside a live one did.
911
+ if (seen.has(kid))
912
+ throw new Error(`witness key ${(0, display_js_1.pyStrRepr)(kid)}: more than one row names this kid`);
913
+ seen.add(kid);
748
914
  let key = value;
915
+ let notAfter = null;
916
+ let until = null;
749
917
  if (isRecordLike(value)) {
918
+ // Read whole: a row read by projection let a misspelled `notAfter` leave a key that never
919
+ // expired. In the `{kid: row}` form a row may repeat its kid, and must agree with it.
920
+ const unknown = Object.keys(value)
921
+ .filter((m) => !TRUST_ROW_MEMBERS.has(m))
922
+ .sort(canonical_js_1.compareCodePoints);
923
+ if (unknown.length > 0) {
924
+ throw new Error(`witness key ${(0, display_js_1.pyStrRepr)(kid)}: the row carries members this build does not evaluate ` +
925
+ `and will not ignore: ${unknown.map(display_js_1.pyStrRepr).join(", ")}`);
926
+ }
927
+ if ("kid" in value && value["kid"] !== kid) {
928
+ throw new Error(`witness key ${(0, display_js_1.pyStrRepr)(kid)}: its row names a different kid, ${(0, display_js_1.pyRepr)(value["kid"])}`);
929
+ }
750
930
  const alg = value["alg"];
751
931
  if (alg !== exports.ENVELOPE_ALG) {
752
- throw new Error(`witness key '${kid}': alg must be '${exports.ENVELOPE_ALG}', got ${pyRepr(alg)}`);
932
+ throw new Error(`witness key ${(0, display_js_1.pyStrRepr)(kid)}: alg must be '${exports.ENVELOPE_ALG}', got ${(0, display_js_1.pyRepr)(alg)}`);
933
+ }
934
+ // Present means checked: `null` is not "no expiry", it is a row that says nothing usable.
935
+ if (value["not_after"] !== undefined) {
936
+ until = rfc3339Utc(value["not_after"]);
937
+ if (until === null) {
938
+ throw new Error(`witness key ${(0, display_js_1.pyStrRepr)(kid)}: not_after must be an RFC 3339 UTC date-time such as ` +
939
+ `'2026-10-05T00:00:00Z', got ${(0, display_js_1.pyRepr)(value["not_after"])}`);
940
+ }
941
+ notAfter = value["not_after"];
753
942
  }
754
943
  key = value["public_key_hex"];
755
944
  }
756
- trusted.set(kid, [exports.ENVELOPE_ALG, witnessPublicKey(kid, key)]);
945
+ const publicKey = witnessPublicKey(kid, key);
946
+ if (until !== null && atOrBefore(until, now)) {
947
+ // Validated in full, then left out. An expired row neither adds its kid nor removes one
948
+ // that another row trusts; it is remembered so the failure can say the key expired, and
949
+ // when, instead of reading as a key nobody ever configured.
950
+ expired.set(kid, notAfter);
951
+ continue;
952
+ }
953
+ trusted.set(kid, [exports.ENVELOPE_ALG, publicKey]);
757
954
  }
758
- return trusted;
955
+ return { trusted, expired };
956
+ }
957
+ /**
958
+ * Every row of a trust set checked as the verifier checks it — kid, alg, public key, `not_after` —
959
+ * and an `Error` naming the kid for the first bad one. The CLI runs this when it loads a
960
+ * `--witness-keys` file, so a bad row is reported against that file before any bundle is read.
961
+ * Expiry is not an error: an expired row is a row the verifier leaves out, not a malformed one.
962
+ */
963
+ function validateWitnessKeys(witnessKeys) {
964
+ trustedWitnesses(witnessKeys, verificationTime(null));
965
+ }
966
+ /** A trust-set row's members. A row is read whole: anything else in it is refused. */
967
+ const TRUST_ROW_MEMBERS = new Set(["kid", "alg", "public_key_hex", "not_after"]);
968
+ /**
969
+ * RFC 3339 `date-time` in UTC: `YYYY-MM-DDTHH:MM:SS`, an optional fraction, and `Z`. RFC 3339
970
+ * allows `t` and `z` in lower case, so they are accepted too. A numeric offset, even `+00:00`, is
971
+ * not: a trust-set row says UTC in the one spelling that cannot be misread as local time. ASCII
972
+ * digits only. The Python implementation accepts exactly this grammar, so a row one of them
973
+ * refuses, both refuse.
974
+ */
975
+ const RFC3339_UTC = /^([0-9]{4})-([0-9]{2})-([0-9]{2})[Tt]([0-9]{2}):([0-9]{2}):([0-9]{2})(?:\.([0-9]+))?[Zz]$/;
976
+ /**
977
+ * `value` as an instant, or `null` when it is not an RFC 3339 UTC date-time. An out-of-range field
978
+ * (month 13, February 30, hour 24, second 60) is `null`: there is no leap second here, as there is
979
+ * none in Python's `datetime`.
980
+ */
981
+ function rfc3339Utc(value) {
982
+ const m = typeof value === "string" ? RFC3339_UTC.exec(value) : null;
983
+ if (m !== null) {
984
+ const [year, month, day, hour, minute, second] = m.slice(1, 7).map(Number);
985
+ const leap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
986
+ const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1];
987
+ if (year >= 1 && days !== undefined && day >= 1 && day <= days && hour <= 23 && minute <= 59 && second <= 59) {
988
+ // `Date.UTC` reads a year below 100 as 19xx; `setUTCFullYear` does not.
989
+ const at = new Date(0);
990
+ at.setUTCFullYear(year, month - 1, day);
991
+ at.setUTCHours(hour, minute, second, 0);
992
+ return { seconds: at.getTime() / 1000, fraction: (m[7] ?? "").slice(0, 6) };
993
+ }
994
+ }
995
+ return null;
996
+ }
997
+ /**
998
+ * The verification time: `now` when given — a `Date`, or a string in the `not_after` grammar — and
999
+ * the current time otherwise.
1000
+ *
1001
+ * A number is refused, never read. The Python implementation takes epoch seconds there; a
1002
+ * JavaScript epoch counts milliseconds, and a number read in the wrong unit moves the verification
1003
+ * time a thousandfold, so this side takes no number at all rather than guess which one it got.
1004
+ */
1005
+ function verificationTime(now) {
1006
+ if (now === null || now === undefined)
1007
+ return dateInstant(new Date());
1008
+ if (now instanceof Date && !Number.isNaN(now.getTime()))
1009
+ return dateInstant(now);
1010
+ if (typeof now === "number") {
1011
+ throw new Error("now must be a Date or an RFC 3339 UTC date-time such as '2026-10-05T00:00:00Z', not a " +
1012
+ "number: a JavaScript epoch counts milliseconds and a Python one seconds");
1013
+ }
1014
+ const parsed = typeof now === "string" ? rfc3339Utc(now) : null;
1015
+ if (parsed === null) {
1016
+ throw new Error("now must be a valid Date or an RFC 3339 UTC date-time such as '2026-10-05T00:00:00Z'; got " +
1017
+ (now instanceof Date ? "an invalid Date" : (0, display_js_1.pyRepr)(now)));
1018
+ }
1019
+ return parsed;
1020
+ }
1021
+ function dateInstant(at) {
1022
+ const ms = at.getTime();
1023
+ const seconds = Math.floor(ms / 1000);
1024
+ return { seconds, fraction: String(ms - seconds * 1000).padStart(3, "0") };
1025
+ }
1026
+ /** `a` is at or before `b`: whole seconds first, then the fraction digits, padded to one length. */
1027
+ function atOrBefore(a, b) {
1028
+ if (a.seconds !== b.seconds)
1029
+ return a.seconds < b.seconds;
1030
+ const width = Math.max(a.fraction.length, b.fraction.length);
1031
+ return a.fraction.padEnd(width, "0") <= b.fraction.padEnd(width, "0");
1032
+ }
1033
+ /**
1034
+ * Python's truthiness for a parsed JSON value, read where the Python implementation writes
1035
+ * `value or default`: absent, null, false, a zero, "", [] and {} are false.
1036
+ */
1037
+ function pyFalsy(value) {
1038
+ if (value === undefined || value === null || value === false || value === 0 || value === "")
1039
+ return true;
1040
+ if (Array.isArray(value))
1041
+ return value.length === 0;
1042
+ if (typeof value === "object")
1043
+ return Object.keys(value).length === 0;
1044
+ return false;
759
1045
  }
760
1046
  /** A plain object (not an array, not null, not a Buffer). */
761
1047
  function isRecordLike(v) {
@@ -778,16 +1064,14 @@ function receivedBytes(raw) {
778
1064
  return Buffer.from(raw);
779
1065
  return null;
780
1066
  }
781
- /**
782
- * A subject `seq` this build will look an entry up by: a JSON integer, and never a boolean.
783
- *
784
- * The type check comes first and every use of `seq` is behind it — in Python an unguarded lookup
785
- * raises on a list or an object and finds the entry at seq 1 for `true`, and the two
786
- * implementations report the same failure for the same bundle.
787
- */
788
- function isSeq(value) {
789
- return typeof value === "number" && Number.isInteger(value);
790
- }
1067
+ // A subject `seq` this build looks an entry up by, and an envelope's `v`, must be an integral
1068
+ // number and never a boolean (`integral`, canonical.ts), as the schema's integer type defines:
1069
+ // Python's `_is_seq`. `1.0` is 1, and RFC 8785 writes it as 1, so an envelope signed over one
1070
+ // verifies as if signed over the other. The type check comes first and every use is behind it — in
1071
+ // Python an unguarded lookup raises on a list or an object and finds the entry at seq 1 for
1072
+ // `true`, and the two implementations report the same failure for the same bundle. The entries are
1073
+ // keyed by the same rule (`subjectIndex`), so an entry whose own seq is `true` or `1.5` is covered
1074
+ // by no envelope.
791
1075
  /**
792
1076
  * The report line: the state and the result together, in the same form for all three results. A
793
1077
  * process-asserted entry gets no result.
@@ -809,43 +1093,45 @@ function envelopeLine(state, result) {
809
1093
  * whoever appends the second decides what the first said, and an entry whose coverage is
810
1094
  * disputed must not read as clean.
811
1095
  */
812
- function scoreEnvelopes(entries, envelopes, trusted, rawBytes) {
1096
+ function scoreEnvelopes(entries, envelopes, trust, rawBytes) {
813
1097
  const fail = new FailureLog();
814
1098
  const states = {};
815
1099
  const results = {};
1100
+ const witnesses = {};
816
1101
  entries.forEach((e, i) => {
817
- states[String(orNull(e["seq"]) ?? i)] = exports.PROCESS_ASSERTED;
1102
+ setOwn(states, stateKey(e, i), exports.PROCESS_ASSERTED);
818
1103
  });
819
1104
  // The hash walk is what an envelope's binding member is checked against; a bundle carrying
820
1105
  // none does not pay for it. Every entry is process-asserted in that case, which is the status
821
1106
  // quo and exactly what this reports.
822
- const bySeq = new Map();
823
- let recomputed = new Map();
824
- if (envelopes.length > 0) {
825
- entries.forEach((e, i) => bySeq.set(orNull(e["seq"]) ?? i, e));
826
- recomputed = recomputedHashes(entries);
827
- }
828
- // seq -> how many envelopes in this array named it, valid or not. `scoreEnvelope` counts an
829
- // envelope in as soon as its subject names an entry this bundle has.
1107
+ const subjectAt = envelopes.length > 0 ? subjectIndex(entries) : new Map();
1108
+ const recomputed = envelopes.length > 0 ? recomputedHashes(entries) : [];
1109
+ // entry index -> how many envelopes in this array named it, valid or not. `scoreEnvelope` counts
1110
+ // an envelope in as soon as its subject names an entry this bundle has.
830
1111
  const claims = new Map();
831
1112
  envelopes.forEach((envelope, index) => {
832
1113
  const raw = rawBytes !== null && index < rawBytes.length ? rawBytes[index] ?? null : null;
833
- const covered = scoreEnvelope(envelope, index, bySeq, recomputed, trusted, raw, fail, claims);
1114
+ const covered = scoreEnvelope(envelope, index, entries, subjectAt, recomputed, trust, raw, fail, claims);
834
1115
  if (covered === null)
835
1116
  return;
836
- states[String(covered.seq)] = exports.WITNESS_SIGNED;
837
- results[String(covered.seq)] = covered.result;
1117
+ // Where `states` files that entry; `witnesses` files it by the index the subject resolved to.
1118
+ const key = stateKey(entries[covered.at], covered.at);
1119
+ setOwn(states, key, exports.WITNESS_SIGNED);
1120
+ setOwn(results, key, covered.result);
1121
+ setOwn(witnesses, String(covered.at), covered.kid);
838
1122
  });
839
1123
  // The first envelope's result stands in `results` — it is what that witness said, and the
840
1124
  // duplicate does not erase it — but the STATE falls back, so a contradicted entry never
841
- // reports witness-signed and the bundle rejects.
842
- for (const [seq, count] of claims) {
843
- if (count > 1)
844
- states[seq] = exports.PROCESS_ASSERTED;
1125
+ // reports witness-signed and the bundle rejects; and it leaves `witnesses`.
1126
+ for (const [at, count] of claims) {
1127
+ if (count > 1) {
1128
+ setOwn(states, stateKey(entries[at], at), exports.PROCESS_ASSERTED);
1129
+ delete witnesses[String(at)];
1130
+ }
845
1131
  }
846
1132
  const lines = {};
847
1133
  for (const [seq, state] of Object.entries(states)) {
848
- lines[seq] = envelopeLine(state, results[seq] ?? null);
1134
+ setOwn(lines, seq, envelopeLine(state, Object.hasOwn(results, seq) ? results[seq] : null));
849
1135
  }
850
1136
  const summary = {
851
1137
  status: fail.length === 0 ? "verified" : "FAILED",
@@ -856,47 +1142,58 @@ function scoreEnvelopes(entries, envelopes, trusted, rawBytes) {
856
1142
  .sort((a, b) => a - b),
857
1143
  states,
858
1144
  results,
1145
+ witnesses,
859
1146
  lines,
860
1147
  failures: [...fail.messages],
861
1148
  };
862
1149
  return [summary, fail];
863
1150
  }
1151
+ /**
1152
+ * `record[key] = value` as an own property, whatever the key. A key is an entry's seq rendered as
1153
+ * text, and a bundle chooses its seqs: a plain assignment of `"__proto__"` would reach the
1154
+ * prototype setter and record nothing.
1155
+ */
1156
+ function setOwn(record, key, value) {
1157
+ Object.defineProperty(record, key, { value, enumerable: true, configurable: true, writable: true });
1158
+ }
864
1159
  /** Python `repr` for a member set, so both implementations print the same failure strings. */
865
1160
  function reprList(values) {
866
- return `[${values.map((v) => `'${v}'`).join(", ")}]`;
1161
+ return `[${values.map(display_js_1.pyStrRepr).join(", ")}]`;
867
1162
  }
868
1163
  /**
869
1164
  * One envelope, checked in the order the seven named failures are defined in.
870
1165
  *
871
- * Returns the covered entry for an envelope that verified, and `null` for one that did not.
872
- * Every failure is positioned on the entry the envelope COVERS, found by `subject.seq` — the
873
- * locators are checked against that entry, not used to find it.
1166
+ * Returns the index in `entries` of the entry an envelope that verified covers, and `null` for one
1167
+ * that did not. Every failure is positioned on the entry the envelope COVERS, found by
1168
+ * `subject.seq` through `subjectIndex` — the locators are checked against that entry, not used to
1169
+ * find it.
874
1170
  *
875
- * `claims` is the caller's seq -> count of the envelopes that have named each entry so far, and
1171
+ * `claims` is the caller's entry index -> count of the envelopes that have named it so far, and
876
1172
  * this function updates it. An envelope claims its entry as soon as `subject.seq` finds one,
877
1173
  * BEFORE the rest of the subject is checked, so a second envelope over an entry an earlier one
878
1174
  * already named is `envelope_duplicate_subject` whether either of them is otherwise sound: the
879
1175
  * point of the check is that no one can decide what an earlier witness said by appending after
880
1176
  * it.
881
1177
  */
882
- function scoreEnvelope(envelope, index, bySeq, recomputed, trusted, raw, fail, claims) {
1178
+ function scoreEnvelope(envelope, index, entries, subjectAt, recomputed, trust, raw, fail, claims) {
883
1179
  const isRecord = (v) => v !== null && typeof v === "object" && !Array.isArray(v) && !(v instanceof canonical_js_1.RawNumber);
884
1180
  const subject = isRecord(envelope) ? envelope["subject"] : undefined;
885
1181
  function position() {
886
1182
  // Every failure is positioned by `subject.seq`, and `subject` is attacker-supplied, so the
887
1183
  // lookup is guarded: a seq that is not an integer positions nothing, which is honest — it
888
1184
  // names no entry — and it is never used as a key.
889
- const s = isRecord(subject) ? (0, canonical_js_1.toPlain)(subject["seq"]) : null;
890
- if (!isSeq(s))
891
- return [null, null];
892
- const entry = bySeq.get(s);
893
- if (entry === undefined)
894
- return [s, null];
895
- return [orNull(entry["seq"]), orNull(entry["node"])];
1185
+ const s = isRecord(subject) ? (0, canonical_js_1.integral)(subject["seq"]) : null;
1186
+ if (s === null)
1187
+ return [null, null, null];
1188
+ const at = subjectAt.get(s);
1189
+ if (at === undefined)
1190
+ return [s, null, null];
1191
+ const entry = entries[at];
1192
+ return [orNull(entry["seq"]), orNull(entry["node"]), entry];
896
1193
  }
897
1194
  function report(reason, detail) {
898
- const [seq, node] = position();
899
- fail.add(reason, `${reason}: ${detail}`, { seq, node });
1195
+ const [seq, node, entry] = position();
1196
+ fail.add(reason, `${reason}: ${detail}`, { seq, node, entry });
900
1197
  return null;
901
1198
  }
902
1199
  if (!isRecord(envelope)) {
@@ -905,11 +1202,11 @@ function scoreEnvelope(envelope, index, bySeq, recomputed, trusted, raw, fail, c
905
1202
  }
906
1203
  // (1) version — a `v` or `typ` this build does not know is a DIFFERENT CONTRACT, and nothing
907
1204
  // further about it can be read safely.
908
- const v = (0, canonical_js_1.toPlain)(envelope["v"]);
1205
+ // The version is read by the seq rule (`integral`): `1.0` is 1, and `true` is not.
909
1206
  const typ = (0, canonical_js_1.toPlain)(envelope["typ"]);
910
- if (v !== exports.ENVELOPE_VERSION || typ !== exports.ENVELOPE_TYP) {
911
- return report("envelope_unknown_version", `envelope v=${pyRepr(v)} typ=${pyRepr(typ)}, this build knows v=${exports.ENVELOPE_VERSION} ` +
912
- `typ='${exports.ENVELOPE_TYP}'`);
1207
+ if ((0, canonical_js_1.integral)(envelope["v"]) !== exports.ENVELOPE_VERSION || typ !== exports.ENVELOPE_TYP) {
1208
+ return report("envelope_unknown_version", `envelope v=${reprIntOr(envelope["v"])} typ=${(0, display_js_1.pyRepr)((envelope["typ"] ?? null))}, ` +
1209
+ `this build knows v=${exports.ENVELOPE_VERSION} typ='${exports.ENVELOPE_TYP}'`);
913
1210
  }
914
1211
  // (2) member sets — the version commits the exact signed member set of the whole envelope, so
915
1212
  // a member added ANYWHERE is a new version that did not declare itself.
@@ -942,7 +1239,7 @@ function scoreEnvelope(envelope, index, bySeq, recomputed, trusted, raw, fail, c
942
1239
  }
943
1240
  const expectedMembers = exports.ENVELOPE_SUBJECT_MEMBERS.get(event);
944
1241
  if (expectedMembers === undefined) {
945
- return report("envelope_subject_mismatch", `subject event=${pyRepr(event)}; envelope v${exports.ENVELOPE_VERSION} defines a subject for ` +
1242
+ return report("envelope_subject_mismatch", `subject event=${(0, display_js_1.pyRepr)(event)}; envelope v${exports.ENVELOPE_VERSION} defines a subject for ` +
946
1243
  `${reprList((0, canonical_js_1.sortedStrings)(exports.ENVELOPE_SUBJECT_MEMBERS.keys()))} and no other event`);
947
1244
  }
948
1245
  const present = (0, canonical_js_1.sortedStrings)(Object.keys(subject));
@@ -958,31 +1255,32 @@ function scoreEnvelope(envelope, index, bySeq, recomputed, trusted, raw, fail, c
958
1255
  // (3a) the binding member. `seq` is the lookup key, so there is nothing to compare it against;
959
1256
  // the entry it finds supplies the hash the subject is checked against. It is also the one
960
1257
  // subject member used as a KEY, so its type is checked before it is used as one.
961
- const subjectSeq = (0, canonical_js_1.toPlain)(subject["seq"]);
962
- if (!isSeq(subjectSeq)) {
1258
+ const subjectSeq = (0, canonical_js_1.integral)(subject["seq"]);
1259
+ if (subjectSeq === null) {
963
1260
  return report("envelope_subject_mismatch", "subject seq is not an integer");
964
1261
  }
965
- const entry = bySeq.get(subjectSeq);
966
- if (entry === undefined) {
967
- return report("envelope_subject_mismatch", `no entry at seq ${pyRepr(subjectSeq)} in this bundle`);
1262
+ const at = subjectAt.get(subjectSeq);
1263
+ if (at === undefined) {
1264
+ return report("envelope_subject_mismatch", `no entry at seq ${reprIntOr(subject["seq"])} in this bundle`);
968
1265
  }
969
- const seq = orNull(entry["seq"]);
1266
+ const entry = entries[at];
1267
+ // The messages below print an integral seq as its integer, and an entry without one as None.
1268
+ const seq = ((0, canonical_js_1.intOr)(entry["seq"]) ?? null);
970
1269
  // (3a') one entry, at most one envelope. Counted here, before anything else about this
971
1270
  // envelope is judged, so the rule cannot be sidestepped by making the second envelope
972
1271
  // defective in some other way as well.
973
- const claimKey = String(seq);
974
- const already = claims.get(claimKey) ?? 0;
975
- claims.set(claimKey, already + 1);
1272
+ const already = claims.get(at) ?? 0;
1273
+ claims.set(at, already + 1);
976
1274
  if (already > 0) {
977
- return report("envelope_duplicate_subject", `seq ${claimKey} is already covered by an earlier envelope in this bundle; two ` +
1275
+ return report("envelope_duplicate_subject", `seq ${(0, display_js_1.pyStr)(seq)} is already covered by an earlier envelope in this bundle; two ` +
978
1276
  "observations of one event contradict each other by construction, so this entry is not " +
979
1277
  "witness-signed");
980
1278
  }
981
- const computed = recomputed.get(seq) ?? null;
1279
+ const computed = recomputed[at] ?? null;
982
1280
  const claimed = (0, canonical_js_1.toPlain)(subject["entry_hash"]);
983
1281
  if (claimed !== computed) {
984
- return report("envelope_subject_mismatch", `subject entry_hash ${pyRepr(claimed)} != the hash recomputed for seq ${String(seq)} from ` +
985
- `this bundle (${pyRepr(computed)})`);
1282
+ return report("envelope_subject_mismatch", `subject entry_hash ${(0, display_js_1.pyRepr)(claimed)} != the hash recomputed for seq ${(0, display_js_1.pyStr)(seq)} from ` +
1283
+ `this bundle (${(0, display_js_1.pyRepr)(computed)})`);
986
1284
  }
987
1285
  // (3b) the locators, checked against the SAME entry `seq` found. A matching locator attests
988
1286
  // nothing on its own; a disagreeing one is the same failure at the same position.
@@ -996,7 +1294,7 @@ function scoreEnvelope(envelope, index, bySeq, recomputed, trusted, raw, fail, c
996
1294
  for (const [member, actual] of locators) {
997
1295
  const stated = (0, canonical_js_1.toPlain)(subject[member]);
998
1296
  if (stated !== actual) {
999
- return report("envelope_subject_mismatch", `subject ${member}=${pyRepr(stated)} != ${pyRepr(actual)} on the entry at seq ${String(seq)}`);
1297
+ return report("envelope_subject_mismatch", `subject ${member}=${(0, display_js_1.pyRepr)(stated)} != ${(0, display_js_1.pyRepr)(actual)} on the entry at seq ${(0, display_js_1.pyStr)(seq)}`);
1000
1298
  }
1001
1299
  }
1002
1300
  // (4) canonicality — an invariant SEPARATE from the signature: the received bytes must equal
@@ -1036,13 +1334,18 @@ function scoreEnvelope(envelope, index, bySeq, recomputed, trusted, raw, fail, c
1036
1334
  if (alg !== exports.ENVELOPE_ALG) {
1037
1335
  // v1 defines Ed25519 and nothing else. Without this, `"alg": "none"` on both sides — in the
1038
1336
  // envelope and in a trust-set row — agreed with each other and read as witness-signed.
1039
- return report("envelope_unknown_witness", `witness alg=${pyRepr(alg)} is not '${exports.ENVELOPE_ALG}'; envelope v${exports.ENVELOPE_VERSION} ` +
1337
+ return report("envelope_unknown_witness", `witness alg=${(0, display_js_1.pyRepr)(alg)} is not '${exports.ENVELOPE_ALG}'; envelope v${exports.ENVELOPE_VERSION} ` +
1040
1338
  "defines Ed25519 and no other algorithm");
1041
1339
  }
1042
- const known = trusted.get(kid);
1340
+ const known = trust.trusted.get(kid);
1043
1341
  if (known === undefined) {
1044
- return report("envelope_unknown_witness", `witness kid=${pyRepr(kid)} alg=${pyRepr(alg)} is not in the trusted witness keys ` +
1045
- `(${reprList((0, canonical_js_1.sortedStrings)(trusted.keys()))})`);
1342
+ // A kid whose rows have all expired is not in the trust set, so it is the same failure as any
1343
+ // other untrusted kid. The message keeps that wording first and then says why, because "not
1344
+ // trusted" and "trusted until a date that has passed" call for different fixes.
1345
+ const lapsed = trust.expired.get(kid);
1346
+ return report("envelope_unknown_witness", `witness kid=${(0, display_js_1.pyRepr)(kid)} alg=${(0, display_js_1.pyRepr)(alg)} is not in the trusted witness keys ` +
1347
+ `(${reprList((0, canonical_js_1.sortedStrings)(trust.trusted.keys()))})` +
1348
+ (lapsed === undefined ? "" : `: the key expired at not_after=${(0, display_js_1.pyRepr)(lapsed)}`));
1046
1349
  }
1047
1350
  // (6) the signature, over JCS(envelope minus "sig").
1048
1351
  const sigHex = (0, canonical_js_1.toPlain)(envelope["sig"]);
@@ -1071,21 +1374,26 @@ function scoreEnvelope(envelope, index, bySeq, recomputed, trusted, raw, fail, c
1071
1374
  verified = false;
1072
1375
  }
1073
1376
  if (!verified) {
1074
- return report("envelope_bad_signature", `the signature does not verify under the key kid=${pyRepr(kid)} names`);
1377
+ return report("envelope_bad_signature", `the signature does not verify under the key kid=${(0, display_js_1.pyRepr)(kid)} names`);
1075
1378
  }
1076
1379
  if (nonCanonical)
1077
1380
  return null;
1078
- return { seq, node: orNull(entry["node"]), result: (0, canonical_js_1.toPlain)(envelope["observed"]["result"]) };
1381
+ return { at, node: orNull(entry["node"]), result: (0, canonical_js_1.toPlain)(envelope["observed"]["result"]), kid };
1079
1382
  }
1080
1383
  /**
1081
1384
  * Score a bundle's observer envelopes on their own, without the ledger checks.
1082
1385
  *
1083
- * Returns `{ok, ...summary, failure_details}`. `states` maps every entry's seq to
1084
- * `witness-signed` or `process-asserted`; `lines` is the report line for each.
1386
+ * Returns `{ok, ...summary, failure_details, failure_entries}`. `states` maps every entry's seq to
1387
+ * `witness-signed` or `process-asserted`; `lines` is the report line for each. `results` maps a
1388
+ * covered seq to the verifying envelope's `observed.result`. `witnesses` maps the index in
1389
+ * `bundle.entries` of every witness-signed entry to that envelope's `witness.kid`: the entry the
1390
+ * subject resolved to, the one `failure_entries` names, so of two entries sharing a seq only the
1391
+ * one the witness signed is in it.
1085
1392
  */
1086
1393
  function verifyEnvelopes(bundle, options = {}) {
1087
- const [summary, fail] = scoreEnvelopes(bundle.entries ?? [], bundle.envelopes ?? [], trustedWitnesses(options.witnessKeys ?? null), options.envelopeBytes ?? null);
1088
- return { ok: fail.length === 0, ...summary, failure_details: fail.details };
1394
+ const entries = bundle.entries ?? [];
1395
+ const [summary, fail] = scoreEnvelopes(entries, bundle.envelopes ?? [], trustedWitnesses(options.witnessKeys ?? null, verificationTime(options.now)), options.envelopeBytes ?? null);
1396
+ return { ok: fail.length === 0, ...summary, failure_details: fail.details, failure_entries: fail.entryIndices(entries) };
1089
1397
  }
1090
1398
  // =============================================================================================
1091
1399
  // Execution binding (0.9.0): offline checks over callId/allow/outcome, from the ledger alone —
@@ -1110,7 +1418,7 @@ function validCallId(e) {
1110
1418
  }
1111
1419
  const cid = (0, canonical_js_1.toPlain)(e["call_id"]);
1112
1420
  if (typeof cid !== "string" || !HEX32.test(cid)) {
1113
- return `call_id missing or malformed (${pyRepr(cid ?? null)})`;
1421
+ return `call_id missing or malformed (${(0, display_js_1.pyRepr)(cid ?? null)})`;
1114
1422
  }
1115
1423
  return null;
1116
1424
  }
@@ -1122,7 +1430,7 @@ function validHashField(e, field) {
1122
1430
  if (v === undefined || v === null)
1123
1431
  return null;
1124
1432
  if (typeof v !== "string" || !HEX64.test(v)) {
1125
- return `${field} malformed (${pyRepr(v)})`;
1433
+ return `${field} malformed (${(0, display_js_1.pyRepr)(v)})`;
1126
1434
  }
1127
1435
  return null;
1128
1436
  }
@@ -1132,7 +1440,7 @@ function validParamsHashReason(e, hashField) {
1132
1440
  }
1133
1441
  const reason = (0, canonical_js_1.toPlain)(e["params_hash_reason"]);
1134
1442
  if (reason !== null && reason !== undefined && !params_js_1.PARAMS_HASH_REASONS.has(reason)) {
1135
- return `params_hash_reason ${pyRepr(reason)} not a known value`;
1443
+ return `params_hash_reason ${(0, display_js_1.pyRepr)(reason)} not a known value`;
1136
1444
  }
1137
1445
  const hash = (0, canonical_js_1.toPlain)(e[hashField]);
1138
1446
  if (reason !== null && reason !== undefined && hash !== null && hash !== undefined) {
@@ -1163,7 +1471,7 @@ function validateAllow(e) {
1163
1471
  return "capture is required on every v2 allow";
1164
1472
  }
1165
1473
  if (!reasons_js_1.CAPTURES.has(capture)) {
1166
- return `capture ${pyRepr(capture)} not a known value`;
1474
+ return `capture ${(0, display_js_1.pyRepr)(capture)} not a known value`;
1167
1475
  }
1168
1476
  if (adapter === null || adapter === undefined) {
1169
1477
  return "adapter is required alongside capture on every v2 allow";
@@ -1174,14 +1482,14 @@ function validateAllow(e) {
1174
1482
  for (const k of ["module", "version", "hook_path"]) {
1175
1483
  const v = adapter[k];
1176
1484
  if (typeof v !== "string" || !v) {
1177
- return `adapter[${JSON.stringify(k)}] must be a non-empty string`;
1485
+ return `adapter[${(0, display_js_1.pyStrRepr)(k)}] must be a non-empty string`;
1178
1486
  }
1179
1487
  }
1180
1488
  if (presentButNull(e, "policy")) {
1181
1489
  return "policy is explicitly null (omit it, or give a valid Policy value)";
1182
1490
  }
1183
1491
  if ("policy" in e && !isKnownPolicy((0, canonical_js_1.toPlain)(e["policy"]))) {
1184
- return `policy ${pyRepr((0, canonical_js_1.toPlain)(e["policy"]))} not a known value`;
1492
+ return `policy ${(0, display_js_1.pyRepr)((0, canonical_js_1.toPlain)(e["policy"]))} not a known value`;
1185
1493
  }
1186
1494
  err = validHashField(e, "authorized_params_hash");
1187
1495
  if (err)
@@ -1202,7 +1510,7 @@ function validateDeny(e) {
1202
1510
  return err;
1203
1511
  const leaked = ALLOW_ONLY_FIELDS.filter((f) => f in e).sort();
1204
1512
  if (leaked.length > 0) {
1205
- return `deny carries allow-only field(s) ${JSON.stringify(leaked)}`;
1513
+ return `deny carries allow-only field(s) ${reprList(leaked)}`;
1206
1514
  }
1207
1515
  return null;
1208
1516
  }
@@ -1214,7 +1522,7 @@ function validateOutcome(e) {
1214
1522
  return "body_state is explicitly null";
1215
1523
  const bodyState = (0, canonical_js_1.toPlain)(e["body_state"]);
1216
1524
  if (typeof bodyState !== "string" || !reasons_js_1.BODY_STATES.has(bodyState)) {
1217
- return `body_state ${pyRepr(bodyState ?? null)} not a known value`;
1525
+ return `body_state ${(0, display_js_1.pyRepr)(bodyState ?? null)} not a known value`;
1218
1526
  }
1219
1527
  if (presentButNull(e, "error_code")) {
1220
1528
  return "error_code is explicitly null (must be a non-empty string or absent)";
@@ -1232,7 +1540,7 @@ function validateOutcome(e) {
1232
1540
  return "duration_ms is explicitly null";
1233
1541
  const duration = (0, canonical_js_1.toPlain)(e["duration_ms"]);
1234
1542
  if (typeof duration !== "number" || !Number.isInteger(duration) || duration < 0) {
1235
- return `duration_ms invalid (${pyRepr(duration ?? null)})`;
1543
+ return `duration_ms invalid (${(0, display_js_1.pyRepr)(duration ?? null)})`;
1236
1544
  }
1237
1545
  const err1 = validHashField(e, "invoked_params_hash");
1238
1546
  if (err1)
@@ -1250,7 +1558,7 @@ function validateOutcome(e) {
1250
1558
  for (const k of ["type", "ref"]) {
1251
1559
  const v = receipt[k];
1252
1560
  if (typeof v !== "string" || !v) {
1253
- return `receipt[${JSON.stringify(k)}] must be a non-empty string`;
1561
+ return `receipt[${(0, display_js_1.pyStrRepr)(k)}] must be a non-empty string`;
1254
1562
  }
1255
1563
  }
1256
1564
  const digest = receipt["digest"];
@@ -1269,7 +1577,7 @@ function validateRoot(e) {
1269
1577
  return "params_salt is explicitly null";
1270
1578
  const salt = (0, canonical_js_1.toPlain)(e["params_salt"]);
1271
1579
  if (typeof salt !== "string" || !/^[0-9a-f]{32}$/.test(salt)) {
1272
- return `params_salt missing or malformed on the v2 root entry (${pyRepr(salt ?? null)})`;
1580
+ return `params_salt missing or malformed on the v2 root entry (${(0, display_js_1.pyRepr)(salt ?? null)})`;
1273
1581
  }
1274
1582
  return null;
1275
1583
  }
@@ -1282,7 +1590,7 @@ function validateKill(e) {
1282
1590
  if (pending === null || pending === undefined)
1283
1591
  return null;
1284
1592
  if (!Array.isArray(pending) || pending.some((c) => typeof c !== "string" || !HEX32.test(c))) {
1285
- return `pending_at_kill must be a list of call_id-shaped strings (${pyRepr(pending)})`;
1593
+ return `pending_at_kill must be a list of call_id-shaped strings (${(0, display_js_1.pyRepr)(pending)})`;
1286
1594
  }
1287
1595
  return null;
1288
1596
  }
@@ -1367,19 +1675,19 @@ function policyFailures(entries, bundleV) {
1367
1675
  if (!("policy" in e))
1368
1676
  continue;
1369
1677
  const ev = (0, canonical_js_1.toPlain)(e["event"]);
1370
- const position = { seq: orNull(e["seq"]), node: orNull(e["node"]) };
1678
+ const position = { seq: orNull(e["seq"]), node: orNull(e["node"]), entry: e };
1371
1679
  if (ev === "allow") {
1372
1680
  if (bundleV === 2)
1373
1681
  continue; // validateAllow owns this entry's message
1374
1682
  if (!isKnownPolicy((0, canonical_js_1.toPlain)(e["policy"]))) {
1375
- failures.add("invalid_policy", `invalid_policy: seq=${pyStr(orNull(e["seq"]))} allow carries policy ` +
1376
- `${pyRepr(orNull(e["policy"]))}, not a value this format defines`, position);
1683
+ failures.add("invalid_policy", `invalid_policy: seq=${(0, display_js_1.shown)((0, canonical_js_1.intOr)(e["seq"]))} allow carries policy ` +
1684
+ `${(0, display_js_1.pyRepr)(e["policy"] ?? null)}, not a value this format defines`, position);
1377
1685
  }
1378
1686
  }
1379
1687
  else {
1380
1688
  if (ev === "deny" && bundleV === 2)
1381
1689
  continue; // validateDeny owns this entry's message
1382
- failures.add("policy_on_non_allow", `policy_on_non_allow: seq=${pyStr(orNull(e["seq"]))} event=${pyRepr(orNull(e["event"]))} ` +
1690
+ failures.add("policy_on_non_allow", `policy_on_non_allow: seq=${(0, display_js_1.shown)((0, canonical_js_1.intOr)(e["seq"]))} event=${(0, display_js_1.pyRepr)(e["event"] ?? null)} ` +
1383
1691
  "carries `policy`, which is an allow-only field", position);
1384
1692
  }
1385
1693
  }
@@ -1390,8 +1698,8 @@ function v2FieldLeaksOnV1(entries) {
1390
1698
  for (const e of entries) {
1391
1699
  const leaked = V2_ONLY_FIELDS.filter((f) => f in e).sort();
1392
1700
  if (leaked.length > 0) {
1393
- 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"]))} ` +
1394
- `carries v2-only field(s) ${JSON.stringify(leaked)} on a schemaVersion: 1 entry`, { seq: orNull(e["seq"]), node: orNull(e["node"]) });
1701
+ failures.add("v2_field_on_v1", `v2_field_on_v1: seq=${(0, display_js_1.shown)((0, canonical_js_1.intOr)(e["seq"]))} event=${(0, display_js_1.pyRepr)(e["event"] ?? null)} ` +
1702
+ `carries v2-only field(s) ${reprList(leaked)} on a schema_version=1 entry`, { seq: orNull(e["seq"]), node: orNull(e["node"]), entry: e });
1395
1703
  }
1396
1704
  }
1397
1705
  return failures;
@@ -1411,7 +1719,8 @@ function executionBinding(entries, bundleV) {
1411
1719
  if (bundleV !== 2)
1412
1720
  return [{ status: "not applicable" }, new FailureLog()];
1413
1721
  const failures = new FailureLog();
1414
- const seenCallIds = new Map(); // callId -> [event, node, seq]
1722
+ // callId -> [event, node, the entry's own seq value as the bundle wrote it]
1723
+ const seenCallIds = new Map();
1415
1724
  const allows = new Map();
1416
1725
  const outcomes = new Map();
1417
1726
  const invalidAllowIds = new Set();
@@ -1420,16 +1729,18 @@ function executionBinding(entries, bundleV) {
1420
1729
  const revokedNodes = new Set();
1421
1730
  for (const e of entries) {
1422
1731
  const ev = (0, canonical_js_1.toPlain)(e["event"]);
1423
- const node = (0, canonical_js_1.toPlain)(e["node"]);
1424
- const seqForEvent = (0, canonical_js_1.toPlain)(e["seq"]);
1732
+ const raw = (0, canonical_js_1.toPlain)(e["node"]);
1733
+ // A node id is a string; anything else names no node, here as in the Python implementation.
1734
+ const node = typeof raw === "string" ? raw : null;
1425
1735
  if (ev === "root") {
1426
1736
  if (node !== null)
1427
1737
  nodes.add(node);
1428
1738
  const err = validateRoot(e);
1429
1739
  if (err) {
1430
- failures.add("invalid_root", `invalid_root: ${err} (seq ${pyRepr(seqForEvent)})`, {
1740
+ failures.add("invalid_root", `invalid_root: ${err} (seq ${(0, display_js_1.shown)((0, canonical_js_1.intOr)(e["seq"]))})`, {
1431
1741
  seq: orNull(e["seq"]),
1432
1742
  node: orNull(e["node"]),
1743
+ entry: e,
1433
1744
  });
1434
1745
  }
1435
1746
  }
@@ -1442,37 +1753,40 @@ function executionBinding(entries, bundleV) {
1442
1753
  finalizedNodes.add(node);
1443
1754
  }
1444
1755
  else if (ev === "kill") {
1445
- for (const r of (0, canonical_js_1.toPlain)(e["revoked"]) ?? [])
1756
+ const killed = new Map();
1757
+ noteRevoked(e, killed);
1758
+ for (const r of killed.keys())
1446
1759
  revokedNodes.add(r);
1447
1760
  const err = validateKill(e);
1448
1761
  if (err) {
1449
- failures.add("invalid_kill", `invalid_kill: ${err} (seq ${pyRepr(seqForEvent)})`, {
1762
+ failures.add("invalid_kill", `invalid_kill: ${err} (seq ${(0, display_js_1.shown)((0, canonical_js_1.intOr)(e["seq"]))})`, {
1450
1763
  seq: orNull(e["seq"]),
1451
1764
  node: orNull(e["node"]),
1765
+ entry: e,
1452
1766
  });
1453
1767
  }
1454
1768
  }
1455
1769
  if (ev === "allow" || ev === "deny") {
1456
1770
  const cid = (0, canonical_js_1.toPlain)(e["call_id"]);
1457
- const seq = (0, canonical_js_1.toPlain)(e["seq"]);
1458
1771
  if (cid !== null && cid !== undefined) {
1459
1772
  const prior = seenCallIds.get(cid);
1460
1773
  if (prior !== undefined) {
1461
1774
  // Positioned on the SECOND sighting: the entry that re-used a call_id is the offending
1462
1775
  // record, the first one having been legitimate when it was written.
1463
- failures.add("duplicate_call_id", `duplicate_call_id: call_id ${cid} on seq ${pyRepr(seq)} (${ev}) already used at seq ` +
1464
- `${pyRepr(prior[2])} (${prior[0]})`, { seq: orNull(e["seq"]), node: orNull(e["node"]), callId: cid });
1776
+ failures.add("duplicate_call_id", `duplicate_call_id: call_id ${(0, display_js_1.shown)(e["call_id"])} on seq ${(0, display_js_1.shown)((0, canonical_js_1.intOr)(e["seq"]))} (${ev}) already used at seq ` +
1777
+ `${(0, display_js_1.shown)((0, canonical_js_1.intOr)(prior[2]))} (${prior[0]})`, { seq: orNull(e["seq"]), node: orNull(e["node"]), callId: cid, entry: e });
1465
1778
  }
1466
1779
  else {
1467
- seenCallIds.set(cid, [ev, node, seq]);
1780
+ seenCallIds.set(cid, [ev, node, e["seq"] ?? null]);
1468
1781
  }
1469
1782
  }
1470
1783
  const err = ev === "allow" ? validateAllow(e) : validateDeny(e);
1471
1784
  if (err) {
1472
- failures.add(`invalid_${ev}`, `invalid_${ev}: ${err} (seq ${pyRepr(seq)})`, {
1785
+ failures.add(`invalid_${ev}`, `invalid_${ev}: ${err} (seq ${(0, display_js_1.shown)((0, canonical_js_1.intOr)(e["seq"]))})`, {
1473
1786
  seq: orNull(e["seq"]),
1474
1787
  node: orNull(e["node"]),
1475
1788
  callId: cid ?? null,
1789
+ entry: e,
1476
1790
  });
1477
1791
  if (ev === "allow" && cid !== null && cid !== undefined)
1478
1792
  invalidAllowIds.add(cid);
@@ -1483,19 +1797,19 @@ function executionBinding(entries, bundleV) {
1483
1797
  }
1484
1798
  else if (ev === "outcome") {
1485
1799
  const cid = (0, canonical_js_1.toPlain)(e["call_id"]);
1486
- const seq = (0, canonical_js_1.toPlain)(e["seq"]);
1487
1800
  const err = validateOutcome(e);
1488
1801
  if (err) {
1489
- failures.add("invalid_outcome", `invalid_outcome: ${err} (seq ${pyRepr(seq)})`, {
1802
+ failures.add("invalid_outcome", `invalid_outcome: ${err} (seq ${(0, display_js_1.shown)((0, canonical_js_1.intOr)(e["seq"]))})`, {
1490
1803
  seq: orNull(e["seq"]),
1491
1804
  node: orNull(e["node"]),
1492
1805
  callId: cid ?? null,
1806
+ entry: e,
1493
1807
  });
1494
1808
  continue;
1495
1809
  }
1496
1810
  if (cid !== null && outcomes.has(cid)) {
1497
- failures.add("duplicate_outcome", `duplicate_outcome: call_id ${cid} at seq ${pyRepr(seq)} (first at seq ` +
1498
- `${pyRepr((0, canonical_js_1.toPlain)(outcomes.get(cid)["seq"]))})`, { seq: orNull(e["seq"]), node: orNull(e["node"]), callId: cid });
1811
+ failures.add("duplicate_outcome", `duplicate_outcome: call_id ${(0, display_js_1.shown)(e["call_id"])} at seq ${(0, display_js_1.shown)((0, canonical_js_1.intOr)(e["seq"]))} (first at seq ` +
1812
+ `${(0, display_js_1.shown)((0, canonical_js_1.intOr)(outcomes.get(cid)["seq"]))})`, { seq: orNull(e["seq"]), node: orNull(e["node"]), callId: cid, entry: e });
1499
1813
  continue;
1500
1814
  }
1501
1815
  if (cid !== null)
@@ -1516,25 +1830,26 @@ function executionBinding(entries, bundleV) {
1516
1830
  for (const [cid, oc] of outcomes) {
1517
1831
  const allowE = allows.get(cid);
1518
1832
  if (allowE === undefined) {
1519
- 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 });
1833
+ failures.add("outcome_without_allow", `outcome_without_allow: call_id ${(0, display_js_1.shown)(oc["call_id"])} at seq ${(0, display_js_1.shown)((0, canonical_js_1.intOr)(oc["seq"]))} has no allow in this chain`, { seq: orNull(oc["seq"]), node: orNull(oc["node"]), callId: cid, entry: oc });
1520
1834
  continue;
1521
1835
  }
1522
1836
  const nodeOk = (0, canonical_js_1.toPlain)(allowE["node"]) === (0, canonical_js_1.toPlain)(oc["node"]);
1523
1837
  if (!nodeOk) {
1524
- failures.add("cross_ref", `cross_ref: call_id ${cid} allow on node ${pyRepr((0, canonical_js_1.toPlain)(allowE["node"]))} but ` +
1525
- `outcome on node ${pyRepr((0, canonical_js_1.toPlain)(oc["node"]))}`, { seq: orNull(oc["seq"]), node: orNull(oc["node"]), callId: cid });
1838
+ failures.add("cross_ref", `cross_ref: call_id ${(0, display_js_1.shown)(oc["call_id"])} allow on node ${(0, display_js_1.pyRepr)(allowE["node"] ?? null)} but ` +
1839
+ `outcome on node ${(0, display_js_1.pyRepr)(oc["node"] ?? null)}`, { seq: orNull(oc["seq"]), node: orNull(oc["node"]), callId: cid, entry: oc });
1526
1840
  }
1527
1841
  const ocSeq = (0, canonical_js_1.toPlain)(oc["seq"]);
1528
1842
  const allowSeq = (0, canonical_js_1.toPlain)(allowE["seq"]);
1529
1843
  const orderOk = typeof ocSeq === "number" && typeof allowSeq === "number" && ocSeq > allowSeq;
1530
1844
  if (!orderOk) {
1531
- failures.add("outcome_before_allow", `outcome_before_allow: call_id ${cid} outcome seq ${pyRepr(ocSeq ?? null)} not ` +
1532
- `after allow seq ${pyRepr(allowSeq ?? null)}`, { seq: orNull(oc["seq"]), node: orNull(oc["node"]), callId: cid });
1845
+ failures.add("outcome_before_allow", `outcome_before_allow: call_id ${(0, display_js_1.shown)(oc["call_id"])} outcome seq ${(0, display_js_1.shown)((0, canonical_js_1.intOr)(oc["seq"]))} not ` +
1846
+ `after allow seq ${(0, display_js_1.shown)((0, canonical_js_1.intOr)(allowE["seq"]))}`, { seq: orNull(oc["seq"]), node: orNull(oc["node"]), callId: cid, entry: oc });
1533
1847
  }
1534
1848
  const ah = (0, canonical_js_1.toPlain)(allowE["authorized_params_hash"]);
1535
1849
  const ih = (0, canonical_js_1.toPlain)(oc["invoked_params_hash"]);
1536
1850
  if (ah !== null && ah !== undefined && ih !== null && ih !== undefined && ah !== ih) {
1537
- 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 });
1851
+ failures.add("params_mismatch", `params_mismatch: call_id ${(0, display_js_1.shown)(oc["call_id"])} authorized_params_hash ` +
1852
+ `${(0, display_js_1.shown)(allowE["authorized_params_hash"])} != invoked_params_hash ${(0, display_js_1.shown)(oc["invoked_params_hash"])}`, { seq: orNull(oc["seq"]), node: orNull(oc["node"]), callId: cid, entry: oc });
1538
1853
  }
1539
1854
  if (nodeOk && orderOk)
1540
1855
  boundOk.add(cid);
@@ -1560,7 +1875,8 @@ function executionBinding(entries, bundleV) {
1560
1875
  }
1561
1876
  else {
1562
1877
  perCall[cid] = "unaccounted";
1563
- const node = (0, canonical_js_1.toPlain)(allowE["node"]);
1878
+ const raw = (0, canonical_js_1.toPlain)(allowE["node"]);
1879
+ const node = typeof raw === "string" ? raw : null;
1564
1880
  const list = nodePending.get(node) ?? [];
1565
1881
  list.push(cid);
1566
1882
  nodePending.set(node, list);
@@ -1616,16 +1932,16 @@ function executionBinding(entries, bundleV) {
1616
1932
  ];
1617
1933
  }
1618
1934
  /**
1619
- * `[seq, node]` of the FIRST entry the hash chain does not reproduce at — position only.
1935
+ * The index of the FIRST entry the hash chain does not reproduce at — position only.
1620
1936
  *
1621
1937
  * `AuditLog.verify` stays the authority on WHETHER the chain is broken and on the message this
1622
1938
  * module reports; this walk exists so the structured twin of that message can say WHERE, which
1623
1939
  * the message's own text does not expose in a parseable form. Mirrors `AuditLog.verify`'s walk
1624
- * exactly (same seq/prev_hash/hash order). `[null, null]` when nothing entry-local is wrong — a
1625
- * consistently re-hashed ledger fails against the signed anchor, not here, and that failure is
1626
- * chain-level.
1940
+ * exactly (same seq/prev_hash/hash order, and the same rule that a seq is an integral number and
1941
+ * never a boolean, `integral`). `null` when nothing entry-local is wrong — a consistently
1942
+ * re-hashed ledger fails against the signed anchor, not here, and that failure is chain-level.
1627
1943
  */
1628
- function integrityPosition(entries) {
1944
+ function integrityBreak(entries) {
1629
1945
  let prev = audit_js_1.GENESIS;
1630
1946
  for (let i = 0; i < entries.length; i++) {
1631
1947
  const e = entries[i];
@@ -1637,19 +1953,19 @@ function integrityPosition(entries) {
1637
1953
  let broken;
1638
1954
  try {
1639
1955
  broken =
1640
- orNull(e["seq"]) !== i ||
1956
+ (0, canonical_js_1.integral)(e["seq"]) !== i ||
1641
1957
  orNull(payload["prev_hash"]) !== prev ||
1642
1958
  (0, audit_js_1.hashEntry)(prev, payload) !== orNull(e["hash"]);
1643
1959
  }
1644
1960
  catch {
1645
1961
  // An unhashable payload is itself the break, at this entry.
1646
- return [orNull(e["seq"]), orNull(e["node"])];
1962
+ return i;
1647
1963
  }
1648
1964
  if (broken)
1649
- return [orNull(e["seq"]), orNull(e["node"])];
1965
+ return i;
1650
1966
  prev = orNull(e["hash"]);
1651
1967
  }
1652
- return [null, null];
1968
+ return null;
1653
1969
  }
1654
1970
  function verifyBundle(bundle, signer = null, options = {}) {
1655
1971
  const entries = bundle.entries ?? [];
@@ -1669,17 +1985,18 @@ function verifyBundle(bundle, signer = null, options = {}) {
1669
1985
  };
1670
1986
  const log = new FailureLog();
1671
1987
  // (0) version: the bundle must declare a schema version this build understands, and — when
1672
- // an anchor is present — the anchor must be anchoring THAT version, not a different one.
1673
- const bundleV = (0, canonical_js_1.toPlain)(bundle.v);
1674
- let versionOk = typeof bundleV === "number" && exports.SUPPORTED_BUNDLE_VERSIONS.has(bundleV);
1988
+ // an anchor is present — the anchor must be anchoring THAT version, not a different one. A
1989
+ // version is read by the seq rule (`integral`): `2.0` is 2, and `true` is not.
1990
+ const rawV = bundle.v;
1991
+ const bundleV = (0, canonical_js_1.integral)(rawV);
1992
+ let versionOk = bundleV !== null && exports.SUPPORTED_BUNDLE_VERSIONS.has(bundleV);
1675
1993
  if (!versionOk) {
1676
1994
  const supported = Array.from(exports.SUPPORTED_BUNDLE_VERSIONS).sort((a, b) => a - b);
1677
- log.add("unsupported_version", `unsupported_version: bundle v=${pyRepr(bundleV)} not in [${supported.join(", ")}]`);
1995
+ log.add("unsupported_version", `unsupported_version: bundle v=${reprIntOr(rawV)} not in [${supported.join(", ")}]`);
1678
1996
  }
1679
- const anchorV = (0, canonical_js_1.toPlain)(anchor["v"]);
1680
- if (anchorPresent && anchorV !== bundleV) {
1997
+ if (anchorPresent && !sameNumber(anchor["v"], rawV)) {
1681
1998
  versionOk = false;
1682
- log.add("anchor_version_mismatch", `anchor_version_mismatch: anchor v=${pyRepr(anchorV)} != bundle v=${pyRepr(bundleV)}`);
1999
+ log.add("anchor_version_mismatch", `anchor_version_mismatch: anchor v=${reprIntOr(anchor["v"])} != bundle v=${reprIntOr(rawV)}`);
1683
2000
  }
1684
2001
  // (0a) exactly one root: a rootless bundle (or one splicing in a second root) would otherwise
1685
2002
  // sail through monotonicity/containment trivially — there is nothing to anchor those checks to.
@@ -1691,17 +2008,19 @@ function verifyBundle(bundle, signer = null, options = {}) {
1691
2008
  const rootEntry = rootEvents.length === 1 ? rootEvents[0] : undefined;
1692
2009
  // 0.9.0: a chain is created at ONE schema version and never mixes (spec section 9) — the root
1693
2010
  // entry's v must equal the bundle's declared v, and no OTHER entry may carry a different v.
1694
- if (rootEntry !== undefined && (0, canonical_js_1.toPlain)(rootEntry["v"]) !== bundleV) {
2011
+ if (rootEntry !== undefined && !sameNumber(rootEntry["v"], rawV)) {
1695
2012
  versionOk = false;
1696
- 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"]) });
2013
+ log.add("root_version_mismatch", `root_version_mismatch: root v=${reprIntOr(rootEntry["v"])} != bundle v=${reprIntOr(rawV)}`, { seq: orNull(rootEntry["seq"]), node: orNull(rootEntry["node"]), entry: rootEntry });
1697
2014
  }
1698
- const mixedEntries = entries.filter((e) => (0, canonical_js_1.toPlain)(e["v"]) !== bundleV);
1699
- 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))));
2015
+ const mixedEntries = entries.filter((e) => !sameNumber(e["v"], rawV));
2016
+ // An entry without `v` is Python's None in the list. Numbers come first in numeric order, then
2017
+ // everything else by its repr, compared by code point as Python compares strings, never by locale.
2018
+ const mixed = pySetOf(mixedEntries.map((e) => ((0, canonical_js_1.intOr)(e["v"]) ?? null))).sort(compareVersions);
1700
2019
  if (mixed.length > 0) {
1701
2020
  versionOk = false;
1702
2021
  // One aggregate message over every offending entry (unchanged); the twin is positioned on
1703
2022
  // the first of them, which is where a reader looks.
1704
- 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"]) });
2023
+ log.add("mixed_entry_versions", `mixed_entry_versions: entries declare v in [${mixed.map((v) => (0, display_js_1.pyRepr)(v)).join(", ")}], bundle v=${reprIntOr(rawV)}`, { seq: orNull(mixedEntries[0]["seq"]), node: orNull(mixedEntries[0]["node"]), entry: mixedEntries[0] });
1705
2024
  }
1706
2025
  checks.version = versionOk;
1707
2026
  // (0b2) every entry must be read WHOLE. `LEDGER_FIELDS` was enforced only on the EXPORT path
@@ -1727,10 +2046,24 @@ function verifyBundle(bundle, signer = null, options = {}) {
1727
2046
  if (extra.length > 0) {
1728
2047
  unknownOk = false;
1729
2048
  log.add("unknown_ledger_fields", `unknown_ledger_fields: entry carries fields this verifier does not evaluate and will ` +
1730
- `not ignore: ${extra.join(", ")}`, { seq: orNull(e["seq"]), node: orNull(e["node"]) });
2049
+ `not ignore: ${extra.map((f) => (0, display_js_1.shown)(f)).join(", ")}`, { seq: orNull(e["seq"]), node: orNull(e["node"]), entry: e });
1731
2050
  }
1732
2051
  }
1733
2052
  checks.ledger_fields = unknownOk;
2053
+ // (0b3) a node id is a string (the schema's `node`). A root, a spawn and an allow are judged by
2054
+ // the checks that read their node, below; any other entry that carries a node which is a list,
2055
+ // an object, a number or a boolean is reported here, never read by projection and never used as
2056
+ // a key. A null node counts as absent.
2057
+ for (const e of entries) {
2058
+ const ev = (0, canonical_js_1.toPlain)(e["event"]);
2059
+ if (ev === "root" || ev === "spawn" || ev === "allow")
2060
+ continue;
2061
+ const node = (0, canonical_js_1.toPlain)(e["node"]);
2062
+ if (node === undefined || node === null || typeof node === "string")
2063
+ continue;
2064
+ log.add("invalid_node", `invalid_node: seq=${(0, display_js_1.shown)((0, canonical_js_1.intOr)(e["seq"]))} event=${(0, display_js_1.pyRepr)((e["event"] ?? null))} ` +
2065
+ `carries node ${(0, display_js_1.shown)(e["node"])}, which is not a string`, { seq: orNull(e["seq"]), node: orNull(e["node"]), entry: e });
2066
+ }
1734
2067
  // (0c) independently retained expected anchor/head: verified against the BUNDLE's actual
1735
2068
  // computed head, never against its own (possibly forged) enclosed anchor.
1736
2069
  const { expectedAnchor = null, expectedHead = null } = options;
@@ -1742,7 +2075,7 @@ function verifyBundle(bundle, signer = null, options = {}) {
1742
2075
  const [expSeq, expHash] = expectedHead;
1743
2076
  if (actualSeq !== expSeq || actualHead !== expHash) {
1744
2077
  expectedOk = false;
1745
- log.add("expected_head_mismatch", `expected_head_mismatch: bundle head is (seq=${actualSeq}, hash=${actualHead}) but the ` +
2078
+ log.add("expected_head_mismatch", `expected_head_mismatch: bundle head is (seq=${actualSeq}, hash=${(0, display_js_1.shown)(actualHead)}) but the ` +
1746
2079
  `independently retained expected head is (seq=${expSeq}, hash=${expHash})`);
1747
2080
  }
1748
2081
  }
@@ -1751,7 +2084,7 @@ function verifyBundle(bundle, signer = null, options = {}) {
1751
2084
  if ((0, canonical_js_1.toPlain)(ea["seq"]) !== actualSeq ||
1752
2085
  (0, canonical_js_1.toPlain)(ea["head"]) !== actualHead ||
1753
2086
  (0, canonical_js_1.toPlain)(ea["chain_id"]) !== (0, canonical_js_1.toPlain)(bundle.chain_id) ||
1754
- (0, canonical_js_1.toPlain)(ea["v"]) !== bundleV) {
2087
+ !sameNumber(ea["v"], rawV)) {
1755
2088
  expectedOk = false;
1756
2089
  log.add("expected_anchor_mismatch", "expected_anchor_mismatch: the bundle's actual (seq, head, chainId, v) does not match " +
1757
2090
  "the independently retained expected anchor");
@@ -1766,22 +2099,28 @@ function verifyBundle(bundle, signer = null, options = {}) {
1766
2099
  const foreign = entries.find((e) => orNull(e["chain_id"]) !== bundleChainId);
1767
2100
  const entriesOk = foreign === undefined;
1768
2101
  if (foreign !== undefined) {
1769
- log.add("chain_id_mismatch", `chain_id_mismatch: an entry does not carry chain_id=${pyRepr(bundleChainId)}`, {
2102
+ log.add("chain_id_mismatch", `chain_id_mismatch: an entry does not carry chain_id=${(0, display_js_1.pyRepr)(bundleChainId)}`, {
1770
2103
  seq: orNull(foreign["seq"]),
1771
2104
  node: orNull(foreign["node"]),
2105
+ entry: foreign,
1772
2106
  });
1773
2107
  }
1774
2108
  const anchorChainId = orNull(anchor["chain_id"]);
1775
2109
  const anchorChainOk = !anchorPresent || anchorChainId === bundleChainId;
1776
2110
  if (!anchorChainOk) {
1777
- log.add("chain_id_mismatch", `chain_id_mismatch: anchor chain_id=${pyRepr(anchorChainId)} != bundle chain_id=${pyRepr(bundleChainId)}`);
2111
+ log.add("chain_id_mismatch", `chain_id_mismatch: anchor chain_id=${(0, display_js_1.pyRepr)(anchorChainId)} != bundle chain_id=${(0, display_js_1.pyRepr)(bundleChainId)}`);
1778
2112
  }
1779
2113
  checks.chain_id = entriesOk && anchorChainOk;
1780
2114
  // (1) integrity: the hash chain, plus the signed anchor when a key is given.
1781
2115
  const [okChain, err] = audit_js_1.AuditLog.verify(entries);
1782
2116
  if (!okChain) {
1783
- const [badSeq, badNode] = integrityPosition(entries);
1784
- log.add("integrity", `integrity: ${err}`, { seq: badSeq, node: badNode });
2117
+ const bad = integrityBreak(entries);
2118
+ const badEntry = bad === null ? null : entries[bad];
2119
+ log.add("integrity", `integrity: ${err}`, {
2120
+ seq: badEntry === null ? null : orNull(badEntry["seq"]),
2121
+ node: badEntry === null ? null : orNull(badEntry["node"]),
2122
+ entry: badEntry,
2123
+ });
1785
2124
  }
1786
2125
  if (signer !== null) {
1787
2126
  const [okAnchor, aerr] = audit_js_1.AuditLog.verifyAnchor(entries, anchor, signer);
@@ -1795,24 +2134,62 @@ function verifyBundle(bundle, signer = null, options = {}) {
1795
2134
  else {
1796
2135
  checks.integrity = okChain;
1797
2136
  }
1798
- const { auth, parent, failures: afail, definedBy } = nodeAuthorities(entries);
2137
+ const { auth, failures: afail } = nodeAuthorities(entries);
1799
2138
  log.extend(afail);
1800
- // (2) monotonicity: every child ⊆ its parent.
2139
+ // (2) monotonicity: every child ⊆ its parent, read in ledger order. Every spawn is checked, and
2140
+ // the node it names as `parent` has to be one the root or an EARLIER spawn defined, not revoked
2141
+ // by an earlier kill, and not the spawn's own node. Through 0.12.0 a spawn whose parent was
2142
+ // absent, null, or named no node in the bundle was skipped, so a child widened past the
2143
+ // authority it was really given verified OK; the process being watched writes that field. A
2144
+ // node is defined once: a second definition is a failure too, since only one of the two could
2145
+ // be read. The Python implementation's same pass, finding for finding.
1801
2146
  let mono = true;
1802
- for (const [node, pid] of parent) {
1803
- if (pid === null || !auth.has(pid) || !auth.has(node))
2147
+ const definedAt = new Map(); // node -> the entry that defined it, as of this point
2148
+ const revokedAt = new Map(); // node -> the seq of the kill that revoked it
2149
+ for (const e of entries) {
2150
+ const ev = (0, canonical_js_1.toPlain)(e["event"]);
2151
+ if (ev === "kill") {
2152
+ noteRevoked(e, revokedAt);
2153
+ continue;
2154
+ }
2155
+ if (ev !== "root" && ev !== "spawn")
1804
2156
  continue;
1805
- const child = auth.get(node);
1806
- const p = auth.get(pid);
1807
- // 0.6.x: the subsumption relation ALONE decides. This used to be gated on a literal,
1808
- // non-wildcard-aware scope difference, which silently accepted a delegation that widened
1809
- // only ttl or a ceiling whenever the child's scopes happened to be literally a subset of
1810
- // the parent's — the child was more powerful and the bundle verified clean.
1811
- if (!child.isNarrowerThan(p)) {
2157
+ const node = (0, canonical_js_1.toPlain)(e["node"]);
2158
+ if (typeof node !== "string")
2159
+ continue; // unreadable, reported by nodeAuthorities
2160
+ const position = { seq: orNull(e["seq"]), node, entry: e };
2161
+ const first = definedAt.get(node);
2162
+ if (first !== undefined) {
1812
2163
  mono = false;
1813
- const spawnE = definedBy.get(node);
1814
- log.add("monotonicity", `monotonicity: ${node} not ⊆ parent ${pid} (${monotonicityDetail(child, p)})`, { seq: spawnE === undefined ? null : orNull(spawnE["seq"]), node });
2164
+ log.add("monotonicity", `monotonicity: ${(0, display_js_1.shown)(node)} is defined a second time in this bundle (first at seq ${(0, display_js_1.shown)((0, canonical_js_1.intOr)(first["seq"]))})`, position);
2165
+ continue;
2166
+ }
2167
+ if (ev === "spawn") {
2168
+ const pid = (0, canonical_js_1.toPlain)(e["parent"]);
2169
+ if (typeof pid !== "string" || !definedAt.has(pid)) {
2170
+ mono = false;
2171
+ log.add("monotonicity", `monotonicity: ${(0, display_js_1.shown)(node)} names no parent defined earlier in this bundle (parent ${(0, display_js_1.shown)(e["parent"])})`, position);
2172
+ }
2173
+ else if (revokedAt.has(pid)) {
2174
+ mono = false;
2175
+ log.add("monotonicity", `monotonicity: ${(0, display_js_1.shown)(node)} is spawned from ${(0, display_js_1.shown)(pid)} after ${(0, display_js_1.shown)(pid)} was revoked at seq ` +
2176
+ (0, display_js_1.shown)((0, canonical_js_1.intOr)(revokedAt.get(pid))), position);
2177
+ }
2178
+ else {
2179
+ // 0.6.x: the subsumption relation ALONE decides. This used to be gated on a literal,
2180
+ // non-wildcard-aware scope difference, which silently accepted a delegation that widened
2181
+ // only ttl or a ceiling whenever the child's scopes happened to be literally a subset of
2182
+ // the parent's. An unreadable authority on either side is reported by nodeAuthorities
2183
+ // and fails this check on its own.
2184
+ const child = auth.get(node);
2185
+ const p = auth.get(pid);
2186
+ if (child !== undefined && p !== undefined && !child.isNarrowerThan(p)) {
2187
+ mono = false;
2188
+ log.add("monotonicity", `monotonicity: ${(0, display_js_1.shown)(node)} not ⊆ parent ${(0, display_js_1.shown)(pid)} (${monotonicityDetail(child, p)})`, position);
2189
+ }
2190
+ }
1815
2191
  }
2192
+ definedAt.set(node, e);
1816
2193
  }
1817
2194
  checks.monotonicity = mono && afail.length === 0;
1818
2195
  // (3) containment: every allowed action's scope within the acting node's authority.
@@ -1822,11 +2199,27 @@ function verifyBundle(bundle, signer = null, options = {}) {
1822
2199
  // a violation the entry never asserted. Such entries are counted as UNGATED and reported as
1823
2200
  // their own number instead: a reader sees how much of the run was actually measured, which is
1824
2201
  // the honest answer and never a silent one.
2202
+ // Read in ledger order, like monotonicity: an allow is judged against a node defined EARLIER (one
2203
+ // defined only later is unknown at that point), and an allow on a node an earlier kill revoked
2204
+ // is outside its authority, since a revoked node holds none.
1825
2205
  let contained = true;
1826
2206
  let actions = 0;
1827
2207
  let ungated = 0;
2208
+ const known = new Set();
2209
+ const killedAt = new Map();
1828
2210
  for (const e of entries) {
1829
- if ((0, canonical_js_1.toPlain)(e["event"]) !== "allow")
2211
+ const ev = (0, canonical_js_1.toPlain)(e["event"]);
2212
+ if (ev === "root" || ev === "spawn") {
2213
+ const defined = (0, canonical_js_1.toPlain)(e["node"]);
2214
+ if (typeof defined === "string")
2215
+ known.add(defined);
2216
+ continue;
2217
+ }
2218
+ if (ev === "kill") {
2219
+ noteRevoked(e, killedAt);
2220
+ continue;
2221
+ }
2222
+ if (ev !== "allow")
1830
2223
  continue;
1831
2224
  if (isKnownPolicy((0, canonical_js_1.toPlain)(e["policy"]))) {
1832
2225
  // Only a policy value the format DEFINES buys the exemption. An entry carrying anything
@@ -1838,21 +2231,39 @@ function verifyBundle(bundle, signer = null, options = {}) {
1838
2231
  actions += 1;
1839
2232
  const node = (0, canonical_js_1.toPlain)(e["node"]);
1840
2233
  const scope = (0, canonical_js_1.toPlain)(e["scope"]);
1841
- const ctx = (0, canonical_js_1.toPlain)(e["context"]) ?? {};
1842
- const a = auth.get(node);
2234
+ // The Python implementation reads `e.get("context") or {}`, so an absent context and any value
2235
+ // Python counts as false (null, false, 0, "", [] or {}) are no context. Any other value that
2236
+ // is not an object is not a context the allow could have been checked against, and it does not
2237
+ // verify: it is the containment finding below. (Python 0.19.0 raises on it.)
2238
+ const rawContext = (0, canonical_js_1.toPlain)(e["context"]);
2239
+ const noContext = pyFalsy(rawContext);
2240
+ const contextIsObject = isRecordLike(rawContext);
2241
+ const ctx = (noContext ? {} : rawContext);
2242
+ const a = typeof node === "string" && known.has(node) ? auth.get(node) : undefined;
1843
2243
  if (a === undefined) {
1844
2244
  contained = false;
1845
- log.add("containment", `containment: allow on unknown node ${node}`, {
2245
+ log.add("containment", `containment: allow on unknown node ${(0, display_js_1.shown)(e["node"])}`, {
1846
2246
  seq: orNull(e["seq"]),
1847
2247
  node: orNull(e["node"]),
1848
2248
  callId: orNull(e["call_id"]),
2249
+ entry: e,
1849
2250
  });
1850
2251
  continue;
1851
2252
  }
1852
- if (!a.permits(scope, ctx).allowed) {
2253
+ if (killedAt.has(node)) {
2254
+ contained = false;
2255
+ log.add("containment", `containment: allow of ${(0, display_js_1.pyRepr)(e["scope"] ?? null)} on ${(0, display_js_1.shown)(e["node"])} after ${(0, display_js_1.shown)(e["node"])} was ` +
2256
+ `revoked at seq ${(0, display_js_1.shown)((0, canonical_js_1.intOr)(killedAt.get(node)))}`, { seq: orNull(e["seq"]), node: orNull(e["node"]), callId: orNull(e["call_id"]), entry: e });
2257
+ continue;
2258
+ }
2259
+ // A scope that is not a string is no scope the node can hold. Against a wildcard it threw
2260
+ // (`startsWith` on a number or null) out of the verifier; it is this finding either way, as
2261
+ // in the Python implementation. A context that is neither absent, nor false to Python, nor an
2262
+ // object is this finding too.
2263
+ if (typeof scope !== "string" || !(noContext || contextIsObject) || !a.permits(scope, ctx).allowed) {
1853
2264
  contained = false;
1854
- log.add("containment", `containment: allow of '${scope}' on ${node} outside its authority ` +
1855
- `[${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"]) });
2265
+ log.add("containment", `containment: allow of ${(0, display_js_1.pyRepr)(e["scope"] ?? null)} on ${(0, display_js_1.shown)(e["node"])} outside its authority ` +
2266
+ reprList(Array.from(a.scopes).sort(canonical_js_1.compareCodePoints)), { seq: orNull(e["seq"]), node: orNull(e["node"]), callId: orNull(e["call_id"]), entry: e });
1856
2267
  }
1857
2268
  }
1858
2269
  checks.containment = contained;
@@ -1866,7 +2277,7 @@ function verifyBundle(bundle, signer = null, options = {}) {
1866
2277
  // nothing — but a PRESENT one has to verify, and a broken one lands in this same list. The
1867
2278
  // per-entry state is reported either way, so a reader sees which hops were covered before
1868
2279
  // reading which one failed.
1869
- const [envelopeSummary, envelopeFailures] = scoreEnvelopes(entries, bundle.envelopes ?? [], trustedWitnesses(options.witnessKeys ?? null), options.envelopeBytes ?? null);
2280
+ const [envelopeSummary, envelopeFailures] = scoreEnvelopes(entries, bundle.envelopes ?? [], trustedWitnesses(options.witnessKeys ?? null, verificationTime(options.now)), options.envelopeBytes ?? null);
1870
2281
  if (bundle.envelopes === undefined) {
1871
2282
  envelopeSummary.status = "not present";
1872
2283
  }
@@ -1890,6 +2301,7 @@ function verifyBundle(bundle, signer = null, options = {}) {
1890
2301
  checks,
1891
2302
  failures: log.messages,
1892
2303
  failure_details: log.details,
2304
+ failure_entries: log.entryIndices(entries),
1893
2305
  nodes: auth.size,
1894
2306
  actions_checked: actions,
1895
2307
  ungated,