@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.
@@ -0,0 +1,218 @@
1
+ // A JSON-lines file read from its end. The ledger files are append-only and
2
+ // in time order, so what a reader usually wants (the last day, the last N
3
+ // rows) sits at the end, and reading the whole file to get there costs the
4
+ // whole file: measured on 17 Sep 2026, a 100k-decision ledger answered a
5
+ // 24-hour window in 1.5 s, 40% of the time it took to answer everything.
6
+ //
7
+ // The file is walked backwards in chunks. Each row goes to a visitor, newest
8
+ // first, which says take, skip or stop. What was taken comes back in file
9
+ // order. A chunk is cut at its first newline: what lies before it is the tail
10
+ // of a row that continues further up and is carried to the next chunk; what
11
+ // lies after it is whole rows, decoded together and split. Newlines are
12
+ // single bytes that never occur inside a multibyte character, so a chunk
13
+ // boundary inside one changes nothing.
14
+ import { open as fsOpen } from "node:fs/promises";
15
+ const NEWLINE = 0x0a;
16
+ export async function readJsonlTail(path, visit, opts = {}) {
17
+ const chunkBytes = Math.max(1, Math.floor(opts.chunkBytes ?? 64 * 1024));
18
+ const openFile = opts.open ?? fsOpen;
19
+ let fh;
20
+ try {
21
+ fh = await openFile(path, "r");
22
+ }
23
+ catch (err) {
24
+ if (err.code === "ENOENT")
25
+ return [];
26
+ throw err;
27
+ }
28
+ // Newest first while reading; reversed once at the end.
29
+ const taken = [];
30
+ let stopped = false;
31
+ const emitLines = (text) => {
32
+ const lines = text.split("\n");
33
+ for (let i = lines.length - 1; i >= 0 && !stopped; i -= 1) {
34
+ const line = lines[i];
35
+ if (line.trim() === "")
36
+ continue;
37
+ const row = JSON.parse(line);
38
+ const verdict = visit(row);
39
+ if (verdict === "take")
40
+ taken.push(row);
41
+ if (verdict === "stop")
42
+ stopped = true;
43
+ }
44
+ };
45
+ try {
46
+ const { size } = await fh.stat();
47
+ const end = opts.endOffset === undefined ? size : Math.min(size, Math.max(0, Math.floor(opts.endOffset)));
48
+ let pos = end;
49
+ // The bytes before the first newline of the chunk just read: the tail of
50
+ // a row whose head is still further up the file.
51
+ let carry = Buffer.alloc(0);
52
+ while (pos > 0 && !stopped) {
53
+ const len = Math.min(chunkBytes, pos);
54
+ pos -= len;
55
+ const buf = Buffer.allocUnsafe(len);
56
+ let got = 0;
57
+ while (got < len) {
58
+ const r = await fh.read(buf, got, len - got, pos + got);
59
+ if (r.bytesRead === 0)
60
+ break;
61
+ got += r.bytesRead;
62
+ }
63
+ const data = carry.length > 0 ? Buffer.concat([buf.subarray(0, got), carry]) : buf.subarray(0, got);
64
+ const first = data.indexOf(NEWLINE);
65
+ if (first === -1) {
66
+ // No row ends in this chunk: all of it belongs to a row further up,
67
+ // unless this is the top of the file, where it is the first row.
68
+ if (pos === 0)
69
+ emitLines(data.toString("utf8"));
70
+ else
71
+ carry = Buffer.from(data);
72
+ continue;
73
+ }
74
+ emitLines(data.toString("utf8", first + 1));
75
+ if (stopped)
76
+ break;
77
+ if (pos === 0) {
78
+ if (first > 0)
79
+ emitLines(data.toString("utf8", 0, first));
80
+ carry = Buffer.alloc(0);
81
+ }
82
+ else {
83
+ carry = Buffer.from(data.subarray(0, first));
84
+ }
85
+ }
86
+ }
87
+ finally {
88
+ await fh.close();
89
+ }
90
+ return taken.reverse();
91
+ }
92
+ /**
93
+ * The start of the first row whose stamp is at or after `targetMs`, or the
94
+ * file size when none is. Rows are treated as time-ordered (one writer, the
95
+ * clock's `now()`); a 60 s slack on the caller covers a clock set back.
96
+ * Each probe reads from the mid offset until the first complete line.
97
+ */
98
+ export async function seekOffsetByTimestamp(path, targetMs, tsOf, opts = {}) {
99
+ const chunkBytes = Math.max(1, Math.floor(opts.chunkBytes ?? 64 * 1024));
100
+ const openFile = opts.open ?? fsOpen;
101
+ let fh;
102
+ try {
103
+ fh = await openFile(path, "r");
104
+ }
105
+ catch (err) {
106
+ if (err.code === "ENOENT")
107
+ return 0;
108
+ throw err;
109
+ }
110
+ try {
111
+ const { size } = await fh.stat();
112
+ if (size === 0)
113
+ return 0;
114
+ const readRange = async (from, len) => {
115
+ if (len <= 0)
116
+ return Buffer.alloc(0);
117
+ const buf = Buffer.allocUnsafe(len);
118
+ let got = 0;
119
+ while (got < len) {
120
+ const r = await fh.read(buf, got, len - got, from + got);
121
+ if (r.bytesRead === 0)
122
+ break;
123
+ got += r.bytesRead;
124
+ }
125
+ return buf.subarray(0, got);
126
+ };
127
+ const probe = Math.min(4096, chunkBytes);
128
+ const parseLine = (start, text, end) => {
129
+ if (text.trim() === "")
130
+ return null;
131
+ return { start, end, row: JSON.parse(text) };
132
+ };
133
+ const firstCompleteLine = async (mid) => {
134
+ const from = mid > 0 ? mid - 1 : 0;
135
+ let collected = await readRange(from, Math.min(probe, size - from));
136
+ if (collected.length === 0)
137
+ return null;
138
+ let start;
139
+ let rel = 0;
140
+ if (mid <= 0) {
141
+ start = 0;
142
+ rel = 0;
143
+ }
144
+ else if (collected[0] === NEWLINE) {
145
+ start = mid;
146
+ rel = 1;
147
+ }
148
+ else {
149
+ let pos = from;
150
+ let buf = collected;
151
+ let found = false;
152
+ for (;;) {
153
+ const nl = buf.indexOf(NEWLINE, pos === from ? 1 : 0);
154
+ if (nl !== -1) {
155
+ start = pos + nl + 1;
156
+ collected = buf;
157
+ rel = nl + 1;
158
+ found = true;
159
+ break;
160
+ }
161
+ pos += buf.length;
162
+ if (pos >= size)
163
+ return null;
164
+ buf = await readRange(pos, Math.min(probe, size - pos));
165
+ if (buf.length === 0)
166
+ return null;
167
+ }
168
+ if (!found)
169
+ return null;
170
+ }
171
+ if (start >= size)
172
+ return null;
173
+ const after = collected.subarray(rel);
174
+ const nl = after.indexOf(NEWLINE);
175
+ if (nl !== -1) {
176
+ const parsed = parseLine(start, after.subarray(0, nl).toString("utf8"), start + nl + 1);
177
+ return parsed ?? firstCompleteLine(start + nl + 1);
178
+ }
179
+ let acc = Buffer.from(after);
180
+ let pos = start + after.length;
181
+ while (pos < size) {
182
+ const chunk = await readRange(pos, Math.min(probe, size - pos));
183
+ if (chunk.length === 0)
184
+ break;
185
+ const n2 = chunk.indexOf(NEWLINE);
186
+ if (n2 !== -1) {
187
+ const parsed = parseLine(start, Buffer.concat([acc, chunk.subarray(0, n2)]).toString("utf8"), start + acc.length + n2 + 1);
188
+ return parsed ?? firstCompleteLine(start + acc.length + n2 + 1);
189
+ }
190
+ acc = Buffer.concat([acc, chunk]);
191
+ pos += chunk.length;
192
+ }
193
+ return parseLine(start, acc.toString("utf8"), size);
194
+ };
195
+ let lo = 0;
196
+ let hi = size;
197
+ let found = size;
198
+ while (lo < hi) {
199
+ const mid = lo + Math.floor((hi - lo) / 2);
200
+ const line = await firstCompleteLine(mid);
201
+ if (line === null || line.start >= hi) {
202
+ hi = mid;
203
+ continue;
204
+ }
205
+ if (tsOf(line.row) >= targetMs) {
206
+ found = line.start;
207
+ hi = mid;
208
+ }
209
+ else {
210
+ lo = line.end > lo ? line.end : lo + 1;
211
+ }
212
+ }
213
+ return found;
214
+ }
215
+ finally {
216
+ await fh.close();
217
+ }
218
+ }
@@ -0,0 +1,88 @@
1
+ export declare const LEGACY_PIECE_ID = "legacy";
2
+ export declare const DEFAULT_PIECE_MAX_ROWS = 50000;
3
+ export declare const DEFAULT_PIECE_MAX_BYTES: number;
4
+ export declare const MANIFEST_NAME = "ledger-manifest.json";
5
+ export declare const INDEX_NAME = "index.jsonl";
6
+ export type LedgerPieceRow = {
7
+ id: string;
8
+ decisions: string;
9
+ effects: string;
10
+ inputs: string;
11
+ n: number;
12
+ effectN: number;
13
+ firstMs: number | null;
14
+ lastMs: number | null;
15
+ lastHash: string | null;
16
+ closed: boolean;
17
+ closedAtMs?: number;
18
+ };
19
+ export type LedgerManifest = {
20
+ version: 1;
21
+ pieces: LedgerPieceRow[];
22
+ countedAtMs: number[];
23
+ };
24
+ export type LedgerIndexLine = {
25
+ ref: string;
26
+ tenantRef: string;
27
+ piece: string;
28
+ ts: number;
29
+ kind: string;
30
+ requestHash: string;
31
+ resolves: string | null;
32
+ hasEffect: boolean;
33
+ reasonCode: string;
34
+ subject: string;
35
+ policyHash: string;
36
+ };
37
+ export type PieceFiles = {
38
+ id: string;
39
+ decisions: string;
40
+ effects: string;
41
+ inputs: string;
42
+ copyDecisions: string;
43
+ copyEffects: string;
44
+ closed: boolean;
45
+ firstMs: number | null;
46
+ lastMs: number | null;
47
+ n: number;
48
+ effectN: number;
49
+ };
50
+ export declare function manifestPath(dir: string): string;
51
+ export declare function indexPath(dir: string): string;
52
+ export declare function legacyPieceRow(): LedgerPieceRow;
53
+ export declare function newPieceRow(id: string): LedgerPieceRow;
54
+ export declare function copyRel(pieceId: string, name: "decisions.jsonl" | "effects.jsonl"): string;
55
+ export declare function nextPieceId(existingIds: string[], atMs: number): string;
56
+ /**
57
+ * Null only when there is no manifest (the flat legacy layout). A manifest
58
+ * that is there but cannot be read or parsed throws: opening as a single
59
+ * legacy piece would chain new rows onto a closed file and hide every piece
60
+ * the manifest named.
61
+ */
62
+ export declare function readLedgerManifest(dir: string): LedgerManifest | null;
63
+ export declare function writeLedgerManifest(dir: string, manifest: LedgerManifest): void;
64
+ export declare function listPieceFiles(dir: string): PieceFiles[];
65
+ export declare function pieceOverlaps(piece: {
66
+ firstMs: number | null;
67
+ lastMs: number | null;
68
+ n: number;
69
+ closed: boolean;
70
+ }, fromMs: number, toMs: number): boolean;
71
+ export declare function parseIndexText(text: string): LedgerIndexLine[];
72
+ export declare function readLedgerIndex(dir: string): LedgerIndexLine[];
73
+ /**
74
+ * Decisions the index knows, by distinct ref. An effect adds a second line
75
+ * for its decision's ref, so a line total would overstate what the index
76
+ * holds and let a short index pass as complete.
77
+ */
78
+ export declare function decisionIndexCount(lines: readonly LedgerIndexLine[]): number;
79
+ /**
80
+ * What the on-disk index names, or null when the file is missing. Doctor
81
+ * compares this to the pieces; a short index is rebuilt on the next open
82
+ * but a running body does not see the missing refs until then.
83
+ */
84
+ export declare function indexCoverage(dir: string): {
85
+ refs: number;
86
+ pieces: Set<string>;
87
+ } | null;
88
+ export declare function manifestExists(dir: string): boolean;
@@ -0,0 +1,225 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { writeFileAtomic } from "./atomic-write.js";
4
+ export const LEGACY_PIECE_ID = "legacy";
5
+ export const DEFAULT_PIECE_MAX_ROWS = 50_000;
6
+ export const DEFAULT_PIECE_MAX_BYTES = 200 * 1024 * 1024;
7
+ export const MANIFEST_NAME = "ledger-manifest.json";
8
+ export const INDEX_NAME = "index.jsonl";
9
+ export function manifestPath(dir) {
10
+ return join(dir, MANIFEST_NAME);
11
+ }
12
+ export function indexPath(dir) {
13
+ return join(dir, INDEX_NAME);
14
+ }
15
+ export function legacyPieceRow() {
16
+ return {
17
+ id: LEGACY_PIECE_ID,
18
+ decisions: "decisions.jsonl",
19
+ effects: "effects.jsonl",
20
+ inputs: "inputs.jsonl",
21
+ n: 0,
22
+ effectN: 0,
23
+ firstMs: null,
24
+ lastMs: null,
25
+ lastHash: null,
26
+ closed: false,
27
+ };
28
+ }
29
+ export function newPieceRow(id) {
30
+ return {
31
+ id,
32
+ decisions: `pieces/${id}/decisions.jsonl`,
33
+ effects: `pieces/${id}/effects.jsonl`,
34
+ inputs: `pieces/${id}/inputs.jsonl`,
35
+ n: 0,
36
+ effectN: 0,
37
+ firstMs: null,
38
+ lastMs: null,
39
+ lastHash: null,
40
+ closed: false,
41
+ };
42
+ }
43
+ export function copyRel(pieceId, name) {
44
+ if (pieceId === LEGACY_PIECE_ID)
45
+ return `evidence-copy/${name}`;
46
+ return `evidence-copy/pieces/${pieceId}/${name}`;
47
+ }
48
+ export function nextPieceId(existingIds, atMs) {
49
+ const d = new Date(atMs);
50
+ const ym = `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}`;
51
+ const letters = "abcdefghijklmnopqrstuvwxyz";
52
+ for (let i = 0; i < letters.length; i += 1) {
53
+ const id = `${ym}-${letters[i]}`;
54
+ if (!existingIds.includes(id))
55
+ return id;
56
+ }
57
+ let n = letters.length;
58
+ for (;;) {
59
+ const id = `${ym}-z${n}`;
60
+ if (!existingIds.includes(id))
61
+ return id;
62
+ n += 1;
63
+ }
64
+ }
65
+ /**
66
+ * Null only when there is no manifest (the flat legacy layout). A manifest
67
+ * that is there but cannot be read or parsed throws: opening as a single
68
+ * legacy piece would chain new rows onto a closed file and hide every piece
69
+ * the manifest named.
70
+ */
71
+ export function readLedgerManifest(dir) {
72
+ let text;
73
+ try {
74
+ text = readFileSync(manifestPath(dir), "utf8");
75
+ }
76
+ catch (err) {
77
+ if (err.code === "ENOENT")
78
+ return null;
79
+ throw new Error("ledger-manifest-unreadable: " + err.message);
80
+ }
81
+ let raw;
82
+ try {
83
+ raw = JSON.parse(text);
84
+ }
85
+ catch (err) {
86
+ throw new Error("ledger-manifest-corrupt: " + err.message);
87
+ }
88
+ if (!raw || raw.version !== 1 || !Array.isArray(raw.pieces) || raw.pieces.length === 0) {
89
+ throw new Error("ledger-manifest-invalid: version 1 with at least one piece expected");
90
+ }
91
+ const pieces = raw.pieces.map(asPieceRow);
92
+ if (pieces.some((p) => p === null))
93
+ throw new Error("ledger-manifest-invalid: piece row missing a field");
94
+ return {
95
+ version: 1,
96
+ pieces: pieces,
97
+ countedAtMs: Array.isArray(raw.countedAtMs)
98
+ ? raw.countedAtMs.filter((n) => typeof n === "number")
99
+ : [],
100
+ };
101
+ }
102
+ function asPieceRow(raw) {
103
+ if (!raw || typeof raw !== "object")
104
+ return null;
105
+ const r = raw;
106
+ if (typeof r.id !== "string" || typeof r.decisions !== "string")
107
+ return null;
108
+ if (typeof r.effects !== "string" || typeof r.inputs !== "string")
109
+ return null;
110
+ if (typeof r.n !== "number" || typeof r.effectN !== "number")
111
+ return null;
112
+ if (typeof r.closed !== "boolean")
113
+ return null;
114
+ return {
115
+ id: r.id,
116
+ decisions: r.decisions,
117
+ effects: r.effects,
118
+ inputs: r.inputs,
119
+ n: r.n,
120
+ effectN: r.effectN,
121
+ firstMs: typeof r.firstMs === "number" ? r.firstMs : null,
122
+ lastMs: typeof r.lastMs === "number" ? r.lastMs : null,
123
+ lastHash: typeof r.lastHash === "string" ? r.lastHash : null,
124
+ closed: r.closed,
125
+ ...(typeof r.closedAtMs === "number" ? { closedAtMs: r.closedAtMs } : {}),
126
+ };
127
+ }
128
+ export function writeLedgerManifest(dir, manifest) {
129
+ writeFileAtomic(manifestPath(dir), `${JSON.stringify(manifest)}\n`);
130
+ }
131
+ export function listPieceFiles(dir) {
132
+ const manifest = readLedgerManifest(dir);
133
+ const pieces = manifest?.pieces ?? [legacyPieceRow()];
134
+ return pieces.map((p) => ({
135
+ id: p.id,
136
+ decisions: join(dir, p.decisions),
137
+ effects: join(dir, p.effects),
138
+ inputs: join(dir, p.inputs),
139
+ copyDecisions: join(dir, copyRel(p.id, "decisions.jsonl")),
140
+ copyEffects: join(dir, copyRel(p.id, "effects.jsonl")),
141
+ closed: p.closed,
142
+ firstMs: p.firstMs,
143
+ lastMs: p.lastMs,
144
+ n: p.n,
145
+ effectN: p.effectN,
146
+ }));
147
+ }
148
+ export function pieceOverlaps(piece, fromMs, toMs) {
149
+ if (piece.firstMs === null || piece.lastMs === null)
150
+ return piece.n > 0 || !piece.closed;
151
+ return piece.lastMs >= fromMs && piece.firstMs < toMs;
152
+ }
153
+ export function parseIndexText(text) {
154
+ const rows = [];
155
+ for (const line of text.split("\n")) {
156
+ if (line === "")
157
+ continue;
158
+ // The index is appended without fsync, so a crash can leave its last
159
+ // line half written. Such a line is skipped; the ref count below then
160
+ // falls short of the ledger's and the index is rebuilt from the pieces.
161
+ let raw;
162
+ try {
163
+ raw = JSON.parse(line);
164
+ }
165
+ catch {
166
+ continue;
167
+ }
168
+ if (typeof raw.ref !== "string" || typeof raw.piece !== "string")
169
+ continue;
170
+ rows.push({
171
+ ref: raw.ref,
172
+ tenantRef: typeof raw.tenantRef === "string" ? raw.tenantRef : "",
173
+ piece: raw.piece,
174
+ ts: typeof raw.ts === "number" ? raw.ts : 0,
175
+ kind: typeof raw.kind === "string" ? raw.kind : "",
176
+ requestHash: typeof raw.requestHash === "string" ? raw.requestHash : "",
177
+ resolves: typeof raw.resolves === "string" ? raw.resolves : null,
178
+ hasEffect: raw.hasEffect === true,
179
+ reasonCode: typeof raw.reasonCode === "string" ? raw.reasonCode : "",
180
+ subject: typeof raw.subject === "string" ? raw.subject : "",
181
+ policyHash: typeof raw.policyHash === "string" ? raw.policyHash : "",
182
+ });
183
+ }
184
+ return rows;
185
+ }
186
+ export function readLedgerIndex(dir) {
187
+ try {
188
+ return parseIndexText(readFileSync(indexPath(dir), "utf8"));
189
+ }
190
+ catch (err) {
191
+ if (err.code === "ENOENT")
192
+ return [];
193
+ throw err;
194
+ }
195
+ }
196
+ /**
197
+ * Decisions the index knows, by distinct ref. An effect adds a second line
198
+ * for its decision's ref, so a line total would overstate what the index
199
+ * holds and let a short index pass as complete.
200
+ */
201
+ export function decisionIndexCount(lines) {
202
+ const refs = new Set();
203
+ for (const l of lines) {
204
+ if (l.kind === "allow" || l.kind === "deny" || l.kind === "defer")
205
+ refs.add(l.ref);
206
+ }
207
+ return refs.size;
208
+ }
209
+ /**
210
+ * What the on-disk index names, or null when the file is missing. Doctor
211
+ * compares this to the pieces; a short index is rebuilt on the next open
212
+ * but a running body does not see the missing refs until then.
213
+ */
214
+ export function indexCoverage(dir) {
215
+ if (!existsSync(indexPath(dir)))
216
+ return null;
217
+ const lines = readLedgerIndex(dir);
218
+ const pieces = new Set();
219
+ for (const line of lines)
220
+ pieces.add(line.piece);
221
+ return { refs: decisionIndexCount(lines), pieces };
222
+ }
223
+ export function manifestExists(dir) {
224
+ return existsSync(manifestPath(dir));
225
+ }
package/dist/ledger.d.ts CHANGED
@@ -7,9 +7,24 @@ export declare const ledgerFs: {
7
7
  open: typeof open;
8
8
  };
9
9
  import { type EffectRow, type SignedEffectExtract } from "@cedulon/effect-extract";
10
- import type { EffectSigner, ExtractWindow, Ledger, LedgerEffect, WitnessClass } from "./types.ts";
10
+ import { type TailVisit } from "./jsonl-tail.ts";
11
+ import type { DecisionInputs, EffectSigner, ExtractWindow, Ledger, LedgerEffect, WitnessClass } from "./types.ts";
11
12
  import type { DecisionKind, SignedDecisionRecord } from "@cedulon/core";
12
13
  export type PermissionCheck = "owner-only" | "not checked on this platform";
14
+ /**
15
+ * A ledger file read from its end through the same seam the reads above use,
16
+ * so a test can count what a window costs.
17
+ */
18
+ export declare function readLedgerTail<T>(path: string, visit: (row: T) => TailVisit, chunkBytesOrOpts?: number | {
19
+ chunkBytes?: number;
20
+ endOffset?: number;
21
+ }): Promise<T[]>;
22
+ /**
23
+ * How far past a window's lower edge the tail reader keeps looking before it
24
+ * stops. Rows are in file order; their stamps are the clock's, and a clock
25
+ * can be set back. A row stamped a minute before its neighbour is still seen.
26
+ */
27
+ export declare const WINDOW_SLACK_MS = 60000;
13
28
  /** Write then fsync so a crash cannot drop a committed line. */
14
29
  export declare function appendDurable(path: string, line: string): Promise<void>;
15
30
  export type RemoteWitnessSign = (row: EffectRow, resultHash: string | undefined) => Promise<Pick<LedgerEffect, "receipt" | "attestation" | "witnessClass"> | null>;
@@ -55,14 +70,37 @@ export declare class MemoryLedger implements Ledger {
55
70
  lastDecisionHash(): Promise<string | null>;
56
71
  exportExtract(window: ExtractWindow, signer: EffectSigner): Promise<SignedEffectExtract>;
57
72
  }
73
+ export type FileLedgerOpts = {
74
+ pieceMaxRows?: number;
75
+ pieceMaxBytes?: number;
76
+ onPieceClose?: (window: {
77
+ startMs: number;
78
+ endMs: number;
79
+ pieceId: string;
80
+ }) => Promise<void>;
81
+ };
82
+ export type LedgerCounts = {
83
+ decisions: number;
84
+ effects: number;
85
+ lastDecisionMs: number | null;
86
+ activeDecisions: number;
87
+ pieces: number;
88
+ };
58
89
  export declare class FileLedger implements Ledger {
59
90
  readonly permissionCheck: PermissionCheck;
60
91
  readonly dir: string;
61
92
  effectSigner?: EffectSigner;
62
93
  /** Optional signer in another process. Null means stay `self`. */
63
94
  remoteWitness?: RemoteWitnessSign;
64
- private readonly decisionsPath;
65
- private readonly effectsPath;
95
+ /** Asked after a piece closes. A missing or failing hook does not block the close. */
96
+ onPieceClose?: FileLedgerOpts["onPieceClose"];
97
+ pieceMaxRows: number;
98
+ pieceMaxBytes: number;
99
+ private decisionsPath;
100
+ private effectsPath;
101
+ private inputsPath;
102
+ private copyDecisionsPath;
103
+ private copyEffectsPath;
66
104
  private readonly lockPath;
67
105
  private readonly q;
68
106
  private readonly token;
@@ -75,17 +113,37 @@ export declare class FileLedger implements Ledger {
75
113
  private readonly reauthByHash;
76
114
  private readonly countedAt;
77
115
  countsReadable: boolean;
78
- private readonly copyDir;
79
116
  private readonly heartbeatPath;
80
117
  private decisionCount;
81
118
  private effectCount;
82
- constructor(dir: string);
119
+ private lastDecisionMs;
120
+ private pieces;
121
+ /** Inputs rows appended through this instance, until their decision lands. */
122
+ private readonly recentInputs;
123
+ private activeId;
124
+ private activeDecisionN;
125
+ private activeEffectN;
126
+ constructor(dir: string, opts?: FileLedgerOpts);
127
+ noteInputs(ref: string, inputs: DecisionInputs): void;
128
+ inputsPaths(): {
129
+ active: string;
130
+ all: string[];
131
+ };
83
132
  /** After lock handoff a new instance reloads; the lost owner cannot append. */
84
133
  private loadCaches;
85
- /** Pair inputs.jsonl with decisions so a restart keeps per-tenant `_ref` keys. */
86
- private loadTenantRefs;
87
- /** Single pass over inputs.jsonl. FileInputsLog itself stays cache-less. */
88
- private loadResolvedBy;
134
+ private applyActivePaths;
135
+ private activePiece;
136
+ private applyIndexLine;
137
+ private pruneCounted;
138
+ private rebuildIndex;
139
+ private writeIndexForDecision;
140
+ private writeIndexHasEffect;
141
+ private noteActiveStamp;
142
+ private persistManifest;
143
+ private fsyncPath;
144
+ private ensurePieceFiles;
145
+ private closeActivePiece;
146
+ private maybeRotate;
89
147
  private lockBody;
90
148
  private ownsLock;
91
149
  /** Lock file on disk, not a process counter. */
@@ -97,6 +155,27 @@ export declare class FileLedger implements Ledger {
97
155
  close(): void;
98
156
  appendDecision(signed: SignedDecisionRecord): Promise<void>;
99
157
  appendDecisionChained(build: (prevRecordHash: string | null) => SignedDecisionRecord): Promise<void>;
158
+ /**
159
+ * What /healthz reports, from memory: the counts are kept on load and on
160
+ * every append. Rereading both files to count lines cost a second per call
161
+ * on a 100k-decision ledger (measured 17 Sep 2026), and the panel asks
162
+ * every five seconds. Null when the decision file could not be parsed on
163
+ * load; then the caller reads for itself and fails the way it always did.
164
+ */
165
+ counts(): LedgerCounts | null;
166
+ /**
167
+ * Decisions stamped in [fromMs, toMs), read from the end of each overlapping
168
+ * piece so the cost is the window's, not the ledger's. With a limit, the
169
+ * newest rows of the window come back and `more` says the window went on,
170
+ * including in a closed piece.
171
+ */
172
+ decisionsWindow(fromMs: number, toMs: number, limit?: number): Promise<{
173
+ rows: SignedDecisionRecord[];
174
+ more: boolean;
175
+ piecesTouched: string[];
176
+ }>;
177
+ /** Effects stamped in [fromMs, toMs), read from the end of each overlapping piece. */
178
+ effectsWindow(fromMs: number, toMs: number): Promise<LedgerEffect[]>;
100
179
  appendEffect(row: EffectRow, witnessClass?: WitnessClass, resultHash?: string): Promise<void>;
101
180
  private appendEffectUnlocked;
102
181
  decisions(): Promise<SignedDecisionRecord[]>;