@verax-ai/proxy 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/ledger.js CHANGED
@@ -1,28 +1,23 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import { mkdir, open, readFile } from "node:fs/promises";
3
- import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
4
4
  /** I/O seam so append-cost tests can count rereads without mocking node:fs. */
5
5
  export const ledgerFs = {
6
6
  readFile,
7
7
  readFileSync,
8
8
  open,
9
9
  };
10
- import { join } from "node:path";
10
+ import { dirname, join } from "node:path";
11
+ import { writeFileAtomic } from "./atomic-write.js";
11
12
  import { canonical, decisionRecordHash } from "@cedulon/core";
12
13
  import { coseToHex, signCoseSign1 } from "@cedulon/cose";
13
14
  import { signEffectExtract, } from "@cedulon/effect-extract";
15
+ import { readJsonlTail, seekOffsetByTimestamp } from "./jsonl-tail.js";
16
+ import { copyRel, decisionIndexCount, DEFAULT_PIECE_MAX_BYTES, DEFAULT_PIECE_MAX_ROWS, indexPath, LEGACY_PIECE_ID, legacyPieceRow, newPieceRow, nextPieceId, pieceOverlaps, readLedgerIndex, readLedgerManifest, writeLedgerManifest, } from "./ledger-manifest.js";
14
17
  import { tenantKey } from "./tenant.js";
15
18
  import { sha256Canonical } from "./hash.js";
19
+ import { SerialQueue } from "./serial-queue.js";
16
20
  const DEFAULT_WITNESS = "self";
17
- /** One async tail so read-then-append cannot fork the chain. */
18
- class SerialQueue {
19
- tail = Promise.resolve();
20
- enqueue(fn) {
21
- const run = this.tail.then(fn, fn);
22
- this.tail = run.then(() => undefined, () => undefined);
23
- return run;
24
- }
25
- }
26
21
  function lineOf(value) {
27
22
  return `${canonical(value)}\n`;
28
23
  }
@@ -55,6 +50,47 @@ function readJsonlSync(path) {
55
50
  throw err;
56
51
  }
57
52
  }
53
+ /**
54
+ * A ledger file read from its end through the same seam the reads above use,
55
+ * so a test can count what a window costs.
56
+ */
57
+ export function readLedgerTail(path, visit, chunkBytesOrOpts) {
58
+ const opts = chunkBytesOrOpts === undefined
59
+ ? {}
60
+ : typeof chunkBytesOrOpts === "number"
61
+ ? { chunkBytes: chunkBytesOrOpts }
62
+ : chunkBytesOrOpts;
63
+ return readJsonlTail(path, visit, { open: ledgerFs.open, ...opts });
64
+ }
65
+ async function readPieceWindow(dir, piece, fileRel, toMs, tsOf, visit) {
66
+ const path = join(dir, fileRel);
67
+ const seekTarget = toMs + WINDOW_SLACK_MS;
68
+ if (piece.lastMs !== null && seekTarget < piece.lastMs) {
69
+ const endOffset = await seekOffsetByTimestamp(path, seekTarget, tsOf, { open: ledgerFs.open });
70
+ return readLedgerTail(path, visit, { endOffset });
71
+ }
72
+ return readLedgerTail(path, visit);
73
+ }
74
+ /**
75
+ * How far past a window's lower edge the tail reader keeps looking before it
76
+ * stops. Rows are in file order; their stamps are the clock's, and a clock
77
+ * can be set back. A row stamped a minute before its neighbour is still seen.
78
+ */
79
+ export const WINDOW_SLACK_MS = 60_000;
80
+ /**
81
+ * Write without fsync. For the ref index only: it is derived from the
82
+ * pieces, a tail lost to a crash is rebuilt on the next open, and the
83
+ * decision's own fsync stays the one per append.
84
+ */
85
+ async function appendPlain(path, line) {
86
+ const fh = await ledgerFs.open(path, "a");
87
+ try {
88
+ await fh.write(line);
89
+ }
90
+ finally {
91
+ await fh.close();
92
+ }
93
+ }
58
94
  /** Write then fsync so a crash cannot drop a committed line. */
59
95
  export async function appendDurable(path, line) {
60
96
  const fh = await ledgerFs.open(path, "a");
@@ -278,8 +314,15 @@ export class FileLedger {
278
314
  effectSigner;
279
315
  /** Optional signer in another process. Null means stay `self`. */
280
316
  remoteWitness;
317
+ /** Asked after a piece closes. A missing or failing hook does not block the close. */
318
+ onPieceClose;
319
+ pieceMaxRows;
320
+ pieceMaxBytes;
281
321
  decisionsPath;
282
322
  effectsPath;
323
+ inputsPath;
324
+ copyDecisionsPath;
325
+ copyEffectsPath;
283
326
  lockPath;
284
327
  q = new SerialQueue();
285
328
  token = randomBytes(16).toString("hex");
@@ -292,20 +335,53 @@ export class FileLedger {
292
335
  reauthByHash = new Map();
293
336
  countedAt = [];
294
337
  countsReadable = true;
295
- copyDir;
296
338
  heartbeatPath;
297
339
  decisionCount = 0;
298
340
  effectCount = 0;
299
- constructor(dir) {
341
+ lastDecisionMs = null;
342
+ pieces = [];
343
+ /** Inputs rows appended through this instance, until their decision lands. */
344
+ recentInputs = new Map();
345
+ activeId = LEGACY_PIECE_ID;
346
+ activeDecisionN = 0;
347
+ activeEffectN = 0;
348
+ constructor(dir, opts) {
300
349
  this.dir = dir;
301
350
  this.permissionCheck = ensureLedgerDir(dir);
351
+ this.pieceMaxRows = opts?.pieceMaxRows ?? DEFAULT_PIECE_MAX_ROWS;
352
+ this.pieceMaxBytes = opts?.pieceMaxBytes ?? DEFAULT_PIECE_MAX_BYTES;
353
+ this.onPieceClose = opts?.onPieceClose;
302
354
  this.decisionsPath = join(dir, "decisions.jsonl");
303
355
  this.effectsPath = join(dir, "effects.jsonl");
356
+ this.inputsPath = join(dir, "inputs.jsonl");
357
+ this.copyDecisionsPath = join(dir, "evidence-copy", "decisions.jsonl");
358
+ this.copyEffectsPath = join(dir, "evidence-copy", "effects.jsonl");
304
359
  this.lockPath = join(dir, "ledger.lock");
305
- this.copyDir = join(dir, "evidence-copy");
306
360
  this.heartbeatPath = join(dir, "heartbeat.json");
307
361
  this.acquireLock();
308
- this.loadCaches();
362
+ try {
363
+ this.loadCaches();
364
+ }
365
+ catch (err) {
366
+ // A ledger that cannot be read is not held: the lock goes back so the
367
+ // operator's next attempt is not refused as "locked" by a dead open.
368
+ this.close();
369
+ throw err;
370
+ }
371
+ }
372
+ noteInputs(ref, inputs) {
373
+ this.recentInputs.set(ref, inputs);
374
+ if (this.recentInputs.size > RECENT_INPUTS_MAX) {
375
+ const oldest = this.recentInputs.keys().next().value;
376
+ if (oldest !== undefined)
377
+ this.recentInputs.delete(oldest);
378
+ }
379
+ }
380
+ inputsPaths() {
381
+ return {
382
+ active: this.inputsPath,
383
+ all: this.pieces.map((p) => join(this.dir, p.inputs)),
384
+ };
309
385
  }
310
386
  /** After lock handoff a new instance reloads; the lost owner cannot append. */
311
387
  loadCaches() {
@@ -315,100 +391,308 @@ export class FileLedger {
315
391
  this.reauthByHash.clear();
316
392
  this.countedAt.length = 0;
317
393
  this.countsReadable = true;
318
- let decisions = [];
394
+ const stored = readLedgerManifest(this.dir);
395
+ this.pieces = stored ? stored.pieces.map((p) => ({ ...p })) : [legacyPieceRow()];
396
+ const open = [...this.pieces].reverse().find((p) => !p.closed) ?? this.pieces[this.pieces.length - 1];
397
+ this.activeId = open.id;
398
+ this.applyActivePaths();
399
+ let activeDecisions = [];
319
400
  try {
320
- decisions = readJsonlSync(this.decisionsPath);
401
+ activeDecisions = readJsonlSync(this.decisionsPath);
321
402
  }
322
403
  catch {
323
404
  this.countsReadable = false;
324
405
  }
325
- this.decisionCount = decisions.length;
326
- const last = decisions[decisions.length - 1];
327
- this.tailHash = last ? decisionRecordHash(last) : null;
328
- const rowsByRef = new Map();
329
- for (const rec of decisions) {
330
- const row = indexRowOf(rec);
331
- if (row) {
332
- this.byRef.set(row.ref, row);
333
- const list = rowsByRef.get(row.ref) ?? [];
334
- list.push(row);
335
- rowsByRef.set(row.ref, list);
336
- noteReauth(this.reauthByHash, row);
337
- noteCounted(this.countedAt, row);
406
+ const closedN = this.pieces.filter((p) => p.closed).reduce((s, p) => s + p.n, 0);
407
+ let indexLines = readLedgerIndex(this.dir);
408
+ if (decisionIndexCount(indexLines) < closedN + activeDecisions.length) {
409
+ indexLines = this.rebuildIndex(activeDecisions);
410
+ }
411
+ for (const line of indexLines)
412
+ this.applyIndexLine(line);
413
+ for (const line of indexLines) {
414
+ if (line.kind === "allow" || line.kind === "defer") {
415
+ noteCounted(this.countedAt, {
416
+ ref: line.ref,
417
+ requestHash: line.requestHash,
418
+ decision: line.kind,
419
+ reasonCode: line.reasonCode,
420
+ subject: line.subject,
421
+ policyHash: line.policyHash,
422
+ timestampMs: line.ts,
423
+ });
338
424
  }
339
425
  }
340
- this.loadTenantRefs(rowsByRef);
426
+ this.pruneCounted(Date.now());
427
+ const last = activeDecisions[activeDecisions.length - 1];
428
+ if (last) {
429
+ this.tailHash = decisionRecordHash(last);
430
+ this.lastDecisionMs = last.claims.timestampMs;
431
+ }
432
+ else {
433
+ const prev = [...this.pieces].reverse().find((p) => p.closed);
434
+ this.tailHash = prev?.lastHash ?? null;
435
+ this.lastDecisionMs = prev?.lastMs ?? null;
436
+ }
437
+ this.activeDecisionN = activeDecisions.length;
438
+ this.decisionCount = closedN + activeDecisions.length;
439
+ if (open.firstMs === null && activeDecisions[0])
440
+ open.firstMs = activeDecisions[0].claims.timestampMs;
441
+ if (activeDecisions.length > 0)
442
+ open.lastMs = activeDecisions[activeDecisions.length - 1].claims.timestampMs;
443
+ open.n = activeDecisions.length;
341
444
  this.effectCount = 0;
342
- for (const effect of readJsonlSync(this.effectsPath)) {
445
+ const activeEffects = readJsonlSync(this.effectsPath);
446
+ for (const effect of activeEffects) {
343
447
  this.effectCount += 1;
344
448
  if (effect.row.effectClass !== "duplicate-effect")
345
449
  this.effectRefs.add(effect.row.ref);
346
450
  }
347
- this.loadResolvedBy();
348
- }
349
- /** Pair inputs.jsonl with decisions so a restart keeps per-tenant `_ref` keys. */
350
- loadTenantRefs(rowsByRef) {
351
- let text;
352
- try {
353
- text = ledgerFs.readFileSync(join(this.dir, "inputs.jsonl"), "utf8");
451
+ this.activeEffectN = activeEffects.length;
452
+ const closedEffects = this.pieces.filter((p) => p.closed).reduce((s, p) => s + p.effectN, 0);
453
+ this.effectCount = closedEffects + activeEffects.length;
454
+ open.effectN = activeEffects.length;
455
+ }
456
+ applyActivePaths() {
457
+ const piece = this.pieces.find((p) => p.id === this.activeId) ?? this.pieces[this.pieces.length - 1];
458
+ this.activeId = piece.id;
459
+ this.decisionsPath = join(this.dir, piece.decisions);
460
+ this.effectsPath = join(this.dir, piece.effects);
461
+ this.inputsPath = join(this.dir, piece.inputs);
462
+ this.copyDecisionsPath = join(this.dir, copyRel(piece.id, "decisions.jsonl"));
463
+ this.copyEffectsPath = join(this.dir, copyRel(piece.id, "effects.jsonl"));
464
+ }
465
+ activePiece() {
466
+ return this.pieces.find((p) => p.id === this.activeId) ?? this.pieces[this.pieces.length - 1];
467
+ }
468
+ applyIndexLine(line) {
469
+ // An effect marker names its decision's ref and nothing else: the
470
+ // decision's own line already carries the row, and counting the marker
471
+ // as a decision doubled the rate-limit work after a restart.
472
+ if (line.kind === "effect") {
473
+ this.effectRefs.add(line.ref);
474
+ return;
354
475
  }
355
- catch (err) {
356
- if (err.code === "ENOENT")
357
- return;
358
- throw err;
476
+ const row = {
477
+ ref: line.ref,
478
+ requestHash: line.requestHash,
479
+ decision: line.kind,
480
+ reasonCode: line.reasonCode,
481
+ subject: line.subject,
482
+ policyHash: line.policyHash,
483
+ timestampMs: line.ts,
484
+ };
485
+ this.byRef.set(line.ref, row);
486
+ if (line.tenantRef !== "")
487
+ this.byRef.set(`${line.tenantRef}:${line.ref}`, row);
488
+ noteReauth(this.reauthByHash, row);
489
+ if (line.resolves) {
490
+ const kind = resolutionKindOf(row);
491
+ if (kind)
492
+ this.resolvedBy.set(line.resolves, { ref: line.ref, kind });
359
493
  }
360
- const seen = new Map();
361
- for (const line of text.split("\n")) {
362
- if (line === "")
363
- continue;
364
- const row = JSON.parse(line);
365
- if (typeof row.ref !== "string")
366
- continue;
367
- const principal = row.inputs?.principal;
368
- if (!principal || typeof principal.brain !== "string")
369
- continue;
370
- const idx = seen.get(row.ref) ?? 0;
371
- seen.set(row.ref, idx + 1);
372
- const indexed = rowsByRef.get(row.ref)?.[idx];
373
- if (!indexed)
374
- continue;
375
- const key = tenantKey({
494
+ if (line.hasEffect)
495
+ this.effectRefs.add(line.ref);
496
+ }
497
+ pruneCounted(nowMs) {
498
+ const day = new Date(nowMs);
499
+ const keepFrom = Date.UTC(day.getUTCFullYear(), day.getUTCMonth(), day.getUTCDate()) - 60_000;
500
+ let w = 0;
501
+ for (const t of this.countedAt) {
502
+ if (t >= keepFrom) {
503
+ this.countedAt[w] = t;
504
+ w += 1;
505
+ }
506
+ }
507
+ this.countedAt.length = w;
508
+ }
509
+ rebuildIndex(activeDecisions) {
510
+ const lines = [];
511
+ for (const piece of this.pieces) {
512
+ const path = join(this.dir, piece.decisions);
513
+ const recs = piece.id === this.activeId ? activeDecisions : readJsonlSync(path);
514
+ const inputLists = inputsByRef(join(this.dir, piece.inputs));
515
+ const effectSet = primaryEffectRefs(join(this.dir, piece.effects));
516
+ const seen = new Map();
517
+ for (const rec of recs) {
518
+ const row = indexRowOf(rec);
519
+ if (!row)
520
+ continue;
521
+ const idx = seen.get(row.ref) ?? 0;
522
+ seen.set(row.ref, idx + 1);
523
+ const inputs = inputLists.get(row.ref)?.[idx];
524
+ const principal = inputs?.principal;
525
+ const tenantRef = principal && typeof principal.brain === "string"
526
+ ? tenantKey({
527
+ brain: principal.brain,
528
+ iss: principal.iss,
529
+ tenant: principal.tenant,
530
+ org: principal.org,
531
+ })
532
+ : "";
533
+ lines.push({
534
+ ref: row.ref,
535
+ tenantRef,
536
+ piece: piece.id,
537
+ ts: row.timestampMs,
538
+ kind: row.decision,
539
+ requestHash: row.requestHash,
540
+ resolves: inputs?.approver?.resolves ?? null,
541
+ hasEffect: effectSet.has(row.ref),
542
+ reasonCode: row.reasonCode,
543
+ subject: row.subject,
544
+ policyHash: row.policyHash,
545
+ });
546
+ }
547
+ }
548
+ const text = lines.map((l) => lineOf(l)).join("");
549
+ writeFileAtomic(indexPath(this.dir), text);
550
+ return lines;
551
+ }
552
+ async writeIndexForDecision(signed) {
553
+ const row = indexRowOf(signed);
554
+ if (!row)
555
+ return;
556
+ // The inputs row was handed over in memory when it was appended (the
557
+ // proxy writes it just before the decision). The tail read is for a row
558
+ // this instance did not write, and it is bounded: an append never reads
559
+ // a piece back.
560
+ let inputs = this.recentInputs.get(row.ref) ?? null;
561
+ if (inputs)
562
+ this.recentInputs.delete(row.ref);
563
+ else
564
+ inputs = await peekInputsTail(this.inputsPath, row.ref);
565
+ const principal = inputs?.principal;
566
+ const tenantRef = principal && typeof principal.brain === "string"
567
+ ? tenantKey({
376
568
  brain: principal.brain,
377
569
  iss: principal.iss,
378
570
  tenant: principal.tenant,
379
571
  org: principal.org,
380
- });
381
- this.byRef.set(`${key}:${row.ref}`, indexed);
382
- }
572
+ })
573
+ : "";
574
+ const line = {
575
+ ref: row.ref,
576
+ tenantRef,
577
+ piece: this.activeId,
578
+ ts: row.timestampMs,
579
+ kind: row.decision,
580
+ requestHash: row.requestHash,
581
+ resolves: inputs?.approver?.resolves ?? null,
582
+ hasEffect: this.effectRefs.has(row.ref),
583
+ reasonCode: row.reasonCode,
584
+ subject: row.subject,
585
+ policyHash: row.policyHash,
586
+ };
587
+ await appendPlain(indexPath(this.dir), lineOf(line));
588
+ this.applyIndexLine(line);
383
589
  }
384
- /** Single pass over inputs.jsonl. FileInputsLog itself stays cache-less. */
385
- loadResolvedBy() {
386
- let text;
590
+ async writeIndexHasEffect(ref) {
591
+ const existing = this.byRef.get(ref);
592
+ if (!existing)
593
+ return;
594
+ const line = {
595
+ ref,
596
+ tenantRef: "",
597
+ piece: this.activeId,
598
+ ts: existing.timestampMs,
599
+ kind: "effect",
600
+ requestHash: "",
601
+ resolves: null,
602
+ hasEffect: true,
603
+ reasonCode: "",
604
+ subject: "",
605
+ policyHash: "",
606
+ };
607
+ await appendPlain(indexPath(this.dir), lineOf(line));
608
+ this.effectRefs.add(ref);
609
+ }
610
+ noteActiveStamp(ts) {
611
+ const piece = this.activePiece();
612
+ if (piece.firstMs === null)
613
+ piece.firstMs = ts;
614
+ piece.lastMs = ts;
615
+ }
616
+ persistManifest() {
617
+ const manifest = {
618
+ version: 1,
619
+ pieces: this.pieces,
620
+ countedAtMs: [...this.countedAt],
621
+ };
622
+ writeLedgerManifest(this.dir, manifest);
623
+ }
624
+ async fsyncPath(path) {
387
625
  try {
388
- text = ledgerFs.readFileSync(join(this.dir, "inputs.jsonl"), "utf8");
626
+ const fh = await ledgerFs.open(path, "r+");
627
+ try {
628
+ await fh.sync();
629
+ }
630
+ finally {
631
+ await fh.close();
632
+ }
389
633
  }
390
634
  catch (err) {
391
635
  if (err.code === "ENOENT")
392
636
  return;
393
637
  throw err;
394
638
  }
395
- for (const line of text.split("\n")) {
396
- if (line === "")
397
- continue;
398
- const row = JSON.parse(line);
399
- if (typeof row.ref !== "string")
400
- continue;
401
- const resolves = row.inputs?.approver?.resolves;
402
- if (typeof resolves !== "string")
403
- continue;
404
- const indexed = this.byRef.get(row.ref);
405
- if (!indexed)
406
- continue;
407
- const kind = resolutionKindOf(indexed);
408
- if (kind)
409
- this.resolvedBy.set(resolves, { ref: row.ref, kind });
639
+ }
640
+ ensurePieceFiles(piece) {
641
+ mkdirSync(join(this.dir, dirname(piece.decisions)), { recursive: true, mode: 0o700 });
642
+ mkdirSync(join(this.dir, dirname(copyRel(piece.id, "decisions.jsonl"))), { recursive: true, mode: 0o700 });
643
+ for (const rel of [piece.decisions, piece.effects, piece.inputs, copyRel(piece.id, "decisions.jsonl"), copyRel(piece.id, "effects.jsonl")]) {
644
+ const path = join(this.dir, rel);
645
+ if (!existsSync(path))
646
+ writeFileSync(path, "", { encoding: "utf8", mode: 0o600 });
647
+ }
648
+ }
649
+ async closeActivePiece() {
650
+ this.assertOwned();
651
+ const closing = this.activePiece();
652
+ await this.fsyncPath(this.decisionsPath);
653
+ await this.fsyncPath(this.effectsPath);
654
+ await this.fsyncPath(this.inputsPath);
655
+ closing.closed = true;
656
+ closing.closedAtMs = Date.now();
657
+ closing.n = this.activeDecisionN;
658
+ closing.effectN = this.activeEffectN;
659
+ closing.lastHash = this.tailHash;
660
+ const nextId = nextPieceId(this.pieces.map((p) => p.id), closing.lastMs ?? Date.now());
661
+ const next = newPieceRow(nextId);
662
+ this.pieces.push(next);
663
+ this.pruneCounted(Date.now());
664
+ this.persistManifest();
665
+ this.activeId = nextId;
666
+ this.applyActivePaths();
667
+ this.ensurePieceFiles(next);
668
+ this.activeDecisionN = 0;
669
+ this.activeEffectN = 0;
670
+ try {
671
+ await this.onPieceClose?.({
672
+ startMs: closing.firstMs ?? 0,
673
+ endMs: (closing.lastMs ?? 0) + 1,
674
+ pieceId: closing.id,
675
+ });
676
+ }
677
+ catch {
678
+ // witness down: the piece stays closed
410
679
  }
411
680
  }
681
+ async maybeRotate() {
682
+ if (this.activeDecisionN >= this.pieceMaxRows) {
683
+ await this.closeActivePiece();
684
+ return;
685
+ }
686
+ let bytes = 0;
687
+ try {
688
+ bytes = statSync(this.decisionsPath).size;
689
+ }
690
+ catch {
691
+ bytes = 0;
692
+ }
693
+ if (bytes >= this.pieceMaxBytes)
694
+ await this.closeActivePiece();
695
+ }
412
696
  lockBody() {
413
697
  return `${JSON.stringify({ pid: process.pid, startedAt: Date.now(), token: this.token })}\n`;
414
698
  }
@@ -472,12 +756,17 @@ export class FileLedger {
472
756
  await appendDurable(this.decisionsPath, line);
473
757
  await this.pulse("decisions.jsonl", line, "decision");
474
758
  this.tailHash = decisionRecordHash(signed);
759
+ this.lastDecisionMs = signed.claims.timestampMs;
760
+ this.noteActiveStamp(signed.claims.timestampMs);
761
+ this.activeDecisionN += 1;
475
762
  const row = indexRowOf(signed);
476
763
  if (row) {
477
764
  this.byRef.set(row.ref, row);
478
765
  noteReauth(this.reauthByHash, row);
479
766
  noteCounted(this.countedAt, row);
480
767
  }
768
+ await this.writeIndexForDecision(signed);
769
+ await this.maybeRotate();
481
770
  });
482
771
  }
483
772
  async appendDecisionChained(build) {
@@ -490,14 +779,110 @@ export class FileLedger {
490
779
  await appendDurable(this.decisionsPath, line);
491
780
  await this.pulse("decisions.jsonl", line, "decision");
492
781
  this.tailHash = decisionRecordHash(signed);
782
+ this.lastDecisionMs = signed.claims.timestampMs;
783
+ this.noteActiveStamp(signed.claims.timestampMs);
784
+ this.activeDecisionN += 1;
493
785
  const row = indexRowOf(signed);
494
786
  if (row) {
495
787
  this.byRef.set(row.ref, row);
496
788
  noteReauth(this.reauthByHash, row);
497
789
  noteCounted(this.countedAt, row);
498
790
  }
791
+ await this.writeIndexForDecision(signed);
792
+ await this.maybeRotate();
499
793
  });
500
794
  }
795
+ /**
796
+ * What /healthz reports, from memory: the counts are kept on load and on
797
+ * every append. Rereading both files to count lines cost a second per call
798
+ * on a 100k-decision ledger (measured 17 Sep 2026), and the panel asks
799
+ * every five seconds. Null when the decision file could not be parsed on
800
+ * load; then the caller reads for itself and fails the way it always did.
801
+ */
802
+ counts() {
803
+ if (!this.countsReadable)
804
+ return null;
805
+ return {
806
+ decisions: this.decisionCount,
807
+ effects: this.effectCount,
808
+ lastDecisionMs: this.lastDecisionMs,
809
+ activeDecisions: this.activeDecisionN,
810
+ pieces: this.pieces.length,
811
+ };
812
+ }
813
+ /**
814
+ * Decisions stamped in [fromMs, toMs), read from the end of each overlapping
815
+ * piece so the cost is the window's, not the ledger's. With a limit, the
816
+ * newest rows of the window come back and `more` says the window went on,
817
+ * including in a closed piece.
818
+ */
819
+ async decisionsWindow(fromMs, toMs, limit) {
820
+ const max = limit === undefined ? Number.POSITIVE_INFINITY : Math.max(0, Math.floor(limit));
821
+ const overlapping = this.pieces.filter((p) => pieceOverlaps(p, fromMs, toMs));
822
+ const piecesTouched = overlapping.map((p) => p.id);
823
+ let taken = 0;
824
+ let more = false;
825
+ let stopOld = false;
826
+ const newestFirst = [...overlapping].reverse();
827
+ const collected = [];
828
+ for (let i = 0; i < newestFirst.length; i += 1) {
829
+ const piece = newestFirst[i];
830
+ if (stopOld)
831
+ break;
832
+ const part = await readPieceWindow(this.dir, piece, piece.decisions, toMs, (row) => row.claims.timestampMs, (row) => {
833
+ const ts = row.claims.timestampMs;
834
+ if (ts >= toMs)
835
+ return "skip";
836
+ if (ts < fromMs) {
837
+ if (ts < fromMs - WINDOW_SLACK_MS) {
838
+ stopOld = true;
839
+ return "stop";
840
+ }
841
+ return "skip";
842
+ }
843
+ if (taken >= max) {
844
+ more = true;
845
+ return "stop";
846
+ }
847
+ taken += 1;
848
+ return "take";
849
+ });
850
+ collected.unshift(...part);
851
+ if (more || stopOld)
852
+ break;
853
+ if (taken >= max && i + 1 < newestFirst.length) {
854
+ more = true;
855
+ break;
856
+ }
857
+ }
858
+ return { rows: collected, more, piecesTouched };
859
+ }
860
+ /** Effects stamped in [fromMs, toMs), read from the end of each overlapping piece. */
861
+ async effectsWindow(fromMs, toMs) {
862
+ const overlapping = this.pieces.filter((p) => pieceOverlaps(p, fromMs, toMs));
863
+ const newestFirst = [...overlapping].reverse();
864
+ const collected = [];
865
+ let stopOld = false;
866
+ for (const piece of newestFirst) {
867
+ if (stopOld)
868
+ break;
869
+ const part = await readPieceWindow(this.dir, piece, piece.effects, toMs, (effect) => effect.row.timestampMs, (effect) => {
870
+ const ts = effect.row.timestampMs;
871
+ if (ts >= toMs)
872
+ return "skip";
873
+ if (ts < fromMs) {
874
+ if (ts < fromMs - WINDOW_SLACK_MS) {
875
+ stopOld = true;
876
+ return "stop";
877
+ }
878
+ return "skip";
879
+ }
880
+ return "take";
881
+ });
882
+ collected.unshift(...part);
883
+ }
884
+ return collected;
885
+ }
501
886
  async appendEffect(row, witnessClass = DEFAULT_WITNESS, resultHash) {
502
887
  return this.q.enqueue(async () => {
503
888
  this.assertOwned();
@@ -515,19 +900,31 @@ export class FileLedger {
515
900
  }, DEFAULT_WITNESS, resultHash, undefined));
516
901
  await appendDurable(this.effectsPath, dup);
517
902
  await this.pulse("effects.jsonl", dup, "effect");
903
+ this.activeEffectN += 1;
518
904
  throw new Error(`duplicate-effect:${row.ref}`);
519
905
  }
520
906
  const line = lineOf(await this.storeEffect(row, witnessClass, resultHash));
521
907
  await appendDurable(this.effectsPath, line);
522
908
  await this.pulse("effects.jsonl", line, "effect");
523
- if (row.effectClass !== "duplicate-effect")
909
+ this.activeEffectN += 1;
910
+ if (row.effectClass !== "duplicate-effect") {
524
911
  this.effectRefs.add(row.ref);
912
+ await this.writeIndexHasEffect(row.ref);
913
+ }
525
914
  }
526
915
  async decisions() {
527
- return readJsonl(this.decisionsPath);
916
+ const out = [];
917
+ for (const piece of this.pieces) {
918
+ out.push(...(await readJsonl(join(this.dir, piece.decisions))));
919
+ }
920
+ return out;
528
921
  }
529
922
  async effects() {
530
- return readJsonl(this.effectsPath);
923
+ const out = [];
924
+ for (const piece of this.pieces) {
925
+ out.push(...(await readJsonl(join(this.dir, piece.effects))));
926
+ }
927
+ return out;
531
928
  }
532
929
  async lastDecisionHash() {
533
930
  return this.lastDecisionHashUnlocked();
@@ -562,8 +959,9 @@ export class FileLedger {
562
959
  return this.effectRefs.has(ref);
563
960
  }
564
961
  async pulse(name, line, kind) {
565
- mkdirSync(this.copyDir, { recursive: true, mode: 0o700 });
566
- appendFileSync(join(this.copyDir, name), line, { encoding: "utf8" });
962
+ const dest = name === "decisions.jsonl" ? this.copyDecisionsPath : this.copyEffectsPath;
963
+ mkdirSync(dirname(dest), { recursive: true, mode: 0o700 });
964
+ appendFileSync(dest, line, { encoding: "utf8" });
567
965
  if (kind === "decision")
568
966
  this.decisionCount += 1;
569
967
  else
@@ -594,6 +992,63 @@ export class FileLedger {
594
992
  return asStoredEffect(row, witnessClass, resultHash, this.effectSigner);
595
993
  }
596
994
  }
995
+ const RECENT_INPUTS_MAX = 4096;
996
+ const PEEK_INPUTS_ROWS = 256;
997
+ /** The newest rows of inputs.jsonl, from the end, for a ref this instance did not append. */
998
+ async function peekInputsTail(path, ref) {
999
+ let visited = 0;
1000
+ let found = false;
1001
+ let rows;
1002
+ try {
1003
+ rows = await readLedgerTail(path, (row) => {
1004
+ if (found)
1005
+ return "stop";
1006
+ visited += 1;
1007
+ if (row.ref === ref) {
1008
+ found = true;
1009
+ return "take";
1010
+ }
1011
+ return visited >= PEEK_INPUTS_ROWS ? "stop" : "skip";
1012
+ });
1013
+ }
1014
+ catch (err) {
1015
+ if (err.code === "ENOENT")
1016
+ return null;
1017
+ throw err;
1018
+ }
1019
+ return rows[0]?.inputs ?? null;
1020
+ }
1021
+ function inputsByRef(path) {
1022
+ const out = new Map();
1023
+ let text;
1024
+ try {
1025
+ text = readFileSync(path, "utf8");
1026
+ }
1027
+ catch (err) {
1028
+ if (err.code === "ENOENT")
1029
+ return out;
1030
+ throw err;
1031
+ }
1032
+ for (const line of text.split("\n")) {
1033
+ if (line === "")
1034
+ continue;
1035
+ const row = JSON.parse(line);
1036
+ if (typeof row.ref !== "string" || !row.inputs)
1037
+ continue;
1038
+ const list = out.get(row.ref) ?? [];
1039
+ list.push(row.inputs);
1040
+ out.set(row.ref, list);
1041
+ }
1042
+ return out;
1043
+ }
1044
+ function primaryEffectRefs(path) {
1045
+ const refs = new Set();
1046
+ for (const effect of readJsonlSync(path)) {
1047
+ if (effect.row.effectClass !== "duplicate-effect")
1048
+ refs.add(effect.row.ref);
1049
+ }
1050
+ return refs;
1051
+ }
597
1052
  function signWindow(effects, window, signer) {
598
1053
  const rows = effects
599
1054
  .filter((e) => e.row.timestampMs >= window.startMs && e.row.timestampMs < window.endMs)