@sakk-dev/sakk 0.0.0-stage → 0.4.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.
package/src/ledger.mjs ADDED
@@ -0,0 +1,1229 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright 2026 The Sakk authors
3
+ import {
4
+ existsSync,
5
+ lstatSync,
6
+ mkdirSync,
7
+ readFileSync,
8
+ realpathSync,
9
+ rmSync,
10
+ writeFileSync,
11
+ } from 'node:fs';
12
+ import { appendNoFollow, writeFileAtomic } from './append.mjs';
13
+ import {
14
+ ArchiveError,
15
+ MAX_INFLIGHT,
16
+ SEAL_AT,
17
+ archivedEntry,
18
+ checkHead,
19
+ coldDir,
20
+ emptyArchive,
21
+ idxDir,
22
+ indexLookup,
23
+ indexSegment,
24
+ leftoverSegment,
25
+ sealSegment,
26
+ validateArchive,
27
+ } from './archive.mjs';
28
+ import { hostname } from 'node:os';
29
+ import { basename, dirname, join } from 'node:path';
30
+
31
+ /**
32
+ * Sakk ledger — the authoritative spend ledger for AI agent actions.
33
+ *
34
+ * ## The single source of cost truth
35
+ *
36
+ * Every potentially paid action (an LLM call, a paid API hit) must RESERVE
37
+ * here before it runs, and SETTLE here after it finishes. Telemetry streams
38
+ * are history, not truth: nothing else participates in the go/no-go decision.
39
+ *
40
+ * ## Fail-closed is this module's entire personality
41
+ *
42
+ * Ledger missing / unreadable / corrupted JSON / wrong schema / stale /
43
+ * cost uncomputable / unresolved reconciliation — every one of these REFUSES
44
+ * new paid calls with a structured error. Nothing is ever treated as "zero
45
+ * spend so far". A fresh ledger can only be created through explicit
46
+ * initialization: an ordinary read failure never auto-creates an empty
47
+ * ledger, because "lost ledger equals free money" is exactly the back door
48
+ * this module exists to close.
49
+ *
50
+ * ## Concurrency and idempotency
51
+ *
52
+ * Read-modify-write runs under a mkdir-based lock (the one correct
53
+ * same-machine primitive), and writes go through temp+rename atomic
54
+ * replacement: two concurrent agents cannot both pass the budget check and
55
+ * jointly overspend. A duplicate callId is refused outright — deduplicated
56
+ * billing with re-executed side effects is precisely how money leaks.
57
+ *
58
+ * ## Accounting rule
59
+ *
60
+ * settled actuals + outstanding reservations (at their estimated maximum,
61
+ * including entries awaiting reconciliation) + this request's estimate
62
+ * **>= capUsd ⇒ refuse**. Reaching the cap counts as exceeding it: the cap
63
+ * is a line you must not touch, not an allowance to fill. Counting
64
+ * outstanding reservations at their maximum is the price of being
65
+ * overdraft-proof under concurrency; settlement releases the difference.
66
+ */
67
+
68
+ export const LEDGER_SCHEMA = 'sakk-ledger.v1';
69
+ /**
70
+ * v2 = v1 + a REQUIRED top-level `halt` record (specs/kill-switch). A ledger
71
+ * becomes v2 the first time it is halted and stays v2: a binary that does
72
+ * not know the kill switch refuses it by schema name instead of spending
73
+ * through a halt it cannot see. Genesis ledgers stay v1 (no `halt` key).
74
+ */
75
+ export const LEDGER_SCHEMA_V2 = 'sakk-ledger.v2';
76
+ /**
77
+ * v3 = + a REQUIRED top-level `archive` summary of the sealed cold trail
78
+ * (F-105, specs/ledger "The hot/cold split"). A ledger becomes v3 the first
79
+ * time settled entries are sealed and stays v3. `halt` is optional in v3
80
+ * (present iff it has ever been halted); a binary that does not know the
81
+ * cold trail refuses v3 by schema name instead of reading its summary as a
82
+ * full ledger and double-spending the cap.
83
+ */
84
+ export const LEDGER_SCHEMA_V3 = 'sakk-ledger.v3';
85
+
86
+ const LEDGER_TOP_KEYS_V1 = [
87
+ 'schema',
88
+ 'initializedAt',
89
+ 'initializedBy',
90
+ 'capUsd',
91
+ 'flags',
92
+ 'entries',
93
+ 'updatedAt',
94
+ ];
95
+ // `halt` is TOP-LEVEL on purpose: four code paths rewrite `flags`
96
+ // wholesale, so a halt stored there would be silently lifted by a settle.
97
+ const LEDGER_TOP_KEYS_V2 = [...LEDGER_TOP_KEYS_V1, 'halt'];
98
+ // v3 adds `archive`; `halt` is allowed but not required.
99
+ const LEDGER_TOP_KEYS_V3 = [...LEDGER_TOP_KEYS_V1, 'halt', 'archive'];
100
+ /** Freshness bound: older than this is refused (every write refreshes updatedAt). */
101
+ export const DEFAULT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
102
+
103
+ /**
104
+ * F-105 (C-04), first part: every stored input is bounded — 1,500
105
+ * reservations with 4 KB call ids and 1e-300 estimates once grew a ledger to
106
+ * 6 MB and each call to 58 ms. The second part, the books growing with use
107
+ * (each call reads and rewrites the whole file), is the archive's job: no
108
+ * entry cap, nothing dropped (owner's decision, 2026-10-22).
109
+ */
110
+ export const LIMITS = Object.freeze({
111
+ idChars: 256,
112
+ nameChars: 256,
113
+ reasonChars: 1024,
114
+ tokensBytes: 4096,
115
+ minEstimateUsd: 1e-6,
116
+ });
117
+ /**
118
+ * A field that is stored: nullish is kept as null, a string is bounded by
119
+ * length, anything else is refused by type (F-150: an array runId or an
120
+ * object taskId skipped the old string-only check and was stored whole).
121
+ */
122
+ function checkLength(field, value, max) {
123
+ if (value === undefined || value === null) return;
124
+ if (typeof value !== 'string')
125
+ throw new LedgerError('INPUT_INVALID', `${field} must be a string when given`, {
126
+ field,
127
+ type: typeof value,
128
+ });
129
+ if (value.length > max)
130
+ throw new LedgerError('INPUT_TOO_LONG', `${field} is longer than ${max} characters`, {
131
+ field,
132
+ length: value.length,
133
+ });
134
+ }
135
+ /** A reason kept for a human: bounded by truncation, never refused (it may quote a long callId). */
136
+ const boundReason = (s) => {
137
+ const t = String(s ?? 'unspecified');
138
+ return t.length > LIMITS.reasonChars ? `${t.slice(0, LIMITS.reasonChars - 1)}…` : t;
139
+ };
140
+ const LOCK_WAIT_MS = 5;
141
+ const LOCK_TRIES = 400; // ~2s of spinning, then a structured error — never proceed lockless
142
+ /**
143
+ * Conservative float guard on the cap comparison. USD amounts are IEEE
144
+ * doubles; accumulated sums can drift a hair BELOW their true value
145
+ * (10 × 0.1 = 0.9999999999999999), which would let a reservation fill the
146
+ * cap exactly — and the cap is a wall, not a target. The epsilon widens the
147
+ * refusal zone, never the allowance.
148
+ */
149
+ const CAP_EPSILON_USD = 1e-9;
150
+
151
+ export class LedgerError extends Error {
152
+ constructor(code, message, details = {}) {
153
+ super(`${code}: ${message}`);
154
+ this.code = code;
155
+ this.details = details;
156
+ }
157
+ }
158
+
159
+ /** Resolve the ledger path from SAKK_LEDGER_PATH; there is no silent default. */
160
+ export function defaultLedgerPath() {
161
+ if (process.env.SAKK_LEDGER_PATH) return process.env.SAKK_LEDGER_PATH;
162
+ throw new LedgerError(
163
+ 'LEDGER_PATH_REQUIRED',
164
+ 'No ledger path given and SAKK_LEDGER_PATH is not set — refusing to guess where the money ledger lives',
165
+ );
166
+ }
167
+
168
+ /**
169
+ * One ledger, one path (F-85). The lock and temp names derive from the path
170
+ * string, and the atomic rename replaces whatever NAME it is given: through
171
+ * a symlinked directory two spellings got two locks; through a symlinked or
172
+ * hard-linked file the first write replaced the link with a new file and the
173
+ * books forked (two $6 reservations under a $10 cap). The directory is
174
+ * resolved; a ledger file that is a link refuses.
175
+ */
176
+ function canonicalLedgerPath(ledgerPath) {
177
+ if (typeof ledgerPath !== 'string' || ledgerPath === '') return ledgerPath;
178
+ let dir;
179
+ try {
180
+ dir = realpathSync(dirname(ledgerPath));
181
+ } catch {
182
+ return ledgerPath; // a missing directory is reported by the operation itself
183
+ }
184
+ const p = join(dir, basename(ledgerPath));
185
+ let st;
186
+ try {
187
+ st = lstatSync(p);
188
+ } catch {
189
+ return p;
190
+ }
191
+ if (st.isSymbolicLink() || (st.isFile() && st.nlink > 1))
192
+ throw new LedgerError(
193
+ 'LEDGER_PATH_NOT_CANONICAL',
194
+ `the ledger file is ${st.isSymbolicLink() ? 'a symbolic link' : 'hard-linked'} — use the one real path; a write would replace the link and fork the books`,
195
+ { ledgerPath },
196
+ );
197
+ return p;
198
+ }
199
+
200
+ /** Where a ledger's alert trail lives: one file shared by every ledger in the folder. */
201
+ export function alertsPathFor(ledgerPath) {
202
+ return join(dirname(ledgerPath), 'sakk-alerts.jsonl');
203
+ }
204
+
205
+ /** Structured alert trail: every budget anomaly leaves one greppable line on disk (best effort, never throws). */
206
+ function emitAlert(ledgerPath, alert) {
207
+ try {
208
+ mkdirSync(dirname(ledgerPath), { recursive: true });
209
+ appendNoFollow(
210
+ alertsPathFor(ledgerPath),
211
+ `${JSON.stringify({ at: new Date().toISOString(), ...alert })}\n`,
212
+ );
213
+ } catch {
214
+ /* an alert write failure must not cascade */
215
+ }
216
+ }
217
+
218
+ function sleepMs(ms) {
219
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
220
+ }
221
+
222
+ /**
223
+ * Break a lock whose recorded owner is a dead process on THIS host.
224
+ *
225
+ * Protocol (post-review v2 — the rename-based v1 had a TOCTOU, finding
226
+ * AR-1): breakers serialize on a breaker mutex (`.breaklock`), and the
227
+ * staleness judgement and the removal happen inside that mutex. A fresh
228
+ * legitimate holder is therefore always visible in owner.json before
229
+ * removal, and no second breaker can act on a stale judgement. A breaker
230
+ * that crashes while holding the mutex disables auto-breaking entirely
231
+ * (fail closed) until `sakk unlock` clears both directories.
232
+ *
233
+ * Conservative on every unknown: cross-host owner, live (or
234
+ * permission-denied) pid, unreadable metadata, or a busy breaker mutex all
235
+ * return false and fall through to the timeout refusal.
236
+ */
237
+ function breakIfStale(ledgerPath, lock) {
238
+ const breaklock = `${lock}.breaklock`;
239
+ try {
240
+ mkdirSync(breaklock); // one breaker at a time — losers keep spinning
241
+ } catch {
242
+ return false;
243
+ }
244
+ try {
245
+ const o = JSON.parse(readFileSync(join(lock, 'owner.json'), 'utf8'));
246
+ if (o.host !== hostname() || !Number.isInteger(o.pid)) return false;
247
+ try {
248
+ process.kill(o.pid, 0); // throws ESRCH when no such process
249
+ return false; // alive — the lock is legitimate
250
+ } catch (e) {
251
+ if (e.code !== 'ESRCH') return false; // EPERM etc.: assume alive
252
+ }
253
+ // Judged dead UNDER the breaker mutex: no other breaker can intervene,
254
+ // and a fresh holder would have shown up in owner.json above.
255
+ rmSync(lock, { recursive: true, force: true });
256
+ emitAlert(ledgerPath, { kind: 'STALE_LOCK_BROKEN', deadPid: o.pid, lockedAt: o.at ?? null });
257
+ return true;
258
+ } catch {
259
+ return false; // unreadable metadata: never break what we cannot judge
260
+ } finally {
261
+ rmSync(breaklock, { recursive: true, force: true });
262
+ }
263
+ }
264
+
265
+ /** Human override for a lock that automatic staleness detection will not touch. Requires a name; leaves an alert. */
266
+ export function forceUnlock({ ledgerPath = defaultLedgerPath(), by }) {
267
+ ledgerPath = canonicalLedgerPath(ledgerPath);
268
+ if (typeof by !== 'string' || by.trim() === '')
269
+ throw new LedgerError('UNLOCK_BY_REQUIRED', 'Breaking a lock by hand requires a name');
270
+ checkLength('by', by, LIMITS.nameChars);
271
+ const lock = `${ledgerPath}.lock`;
272
+ rmSync(`${lock}.breaklock`, { recursive: true, force: true }); // a crashed breaker parks here
273
+ if (!existsSync(lock)) return { ok: true, removed: false };
274
+ rmSync(lock, { recursive: true, force: true });
275
+ emitAlert(ledgerPath, { kind: 'LOCK_FORCED_OPEN', by });
276
+ return { ok: true, removed: true };
277
+ }
278
+
279
+ // Process-local registry of locks WE hold: release authority by
280
+ // construction, with no file read whose error path could be abused (AR-6).
281
+ const HELD_LOCKS = new Set();
282
+
283
+ function acquireLock(ledgerPath) {
284
+ const lock = `${ledgerPath}.lock`;
285
+ try {
286
+ mkdirSync(dirname(ledgerPath), { recursive: true });
287
+ } catch (e) {
288
+ // a typed refusal, not a raw fs error that crashes the caller (MCP review S1)
289
+ throw new LedgerError(
290
+ 'LEDGER_WRITE_FAILED',
291
+ `the ledger directory cannot be created (${e.code ?? e.message})`,
292
+ { ledgerPath },
293
+ );
294
+ }
295
+ for (let i = 0; i < LOCK_TRIES; i += 1) {
296
+ try {
297
+ mkdirSync(lock);
298
+ } catch {
299
+ if (breakIfStale(ledgerPath, lock)) continue; // dead owner cleared — retake at once
300
+ sleepMs(LOCK_WAIT_MS);
301
+ continue;
302
+ }
303
+ try {
304
+ // Owner metadata makes crashed holders detectable (see breakIfStale).
305
+ writeFileSync(
306
+ join(lock, 'owner.json'),
307
+ JSON.stringify({ pid: process.pid, host: hostname(), at: new Date().toISOString() }),
308
+ );
309
+ } catch {
310
+ /* a lock without metadata still mutually excludes; it just needs a human to break */
311
+ }
312
+ HELD_LOCKS.add(lock);
313
+ return lock;
314
+ }
315
+ throw new LedgerError(
316
+ 'LEDGER_LOCK_UNAVAILABLE',
317
+ 'Timed out acquiring the ledger lock — a live holder, or a crashed one needing `sakk unlock`',
318
+ { ledgerPath },
319
+ );
320
+ }
321
+
322
+ function releaseLock(lock) {
323
+ // Release authority = the process-local registry (we added it when we
324
+ // acquired). Anything else — including unreadable metadata — refuses:
325
+ // the error path of an ownership check must never reopen the door the
326
+ // check closes (review finding AR-6).
327
+ if (!HELD_LOCKS.has(lock)) {
328
+ emitAlert(lock.replace(/\.lock$/, ''), { kind: 'LOCK_RELEASE_MISMATCH' });
329
+ return;
330
+ }
331
+ HELD_LOCKS.delete(lock);
332
+ rmSync(lock, { recursive: true, force: true });
333
+ }
334
+
335
+ const isFiniteNonNeg = (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0;
336
+ const VALID_STATUS = new Set(['RESERVED', 'SETTLED', 'RECONCILIATION_REQUIRED']);
337
+
338
+ /**
339
+ * Read and validate the ledger. All fail-closed criteria live in this one
340
+ * function; every violation throws a structured error that callers must not
341
+ * soften.
342
+ */
343
+ export function readLedger({
344
+ ledgerPath = defaultLedgerPath(),
345
+ maxAgeMs = DEFAULT_MAX_AGE_MS,
346
+ now = Date.now(),
347
+ } = {}) {
348
+ ledgerPath = canonicalLedgerPath(ledgerPath);
349
+ if (!existsSync(ledgerPath))
350
+ throw new LedgerError(
351
+ 'LEDGER_MISSING',
352
+ 'The authoritative ledger does not exist — paid calls are refused (create one with explicit init)',
353
+ { ledgerPath },
354
+ );
355
+ let raw;
356
+ try {
357
+ // a fifo at the ledger path blocked reserve forever with the lock held
358
+ // (ledger review N3): regular files only
359
+ if (!lstatSync(ledgerPath).isFile())
360
+ throw new LedgerError('LEDGER_UNREADABLE', 'the ledger path is not a regular file', {
361
+ ledgerPath,
362
+ });
363
+ raw = readFileSync(ledgerPath, 'utf8');
364
+ } catch (e) {
365
+ throw new LedgerError('LEDGER_UNREADABLE', 'Ledger cannot be read — paid calls are refused', {
366
+ ledgerPath,
367
+ why: String(e?.code ?? e?.message),
368
+ });
369
+ }
370
+ let doc;
371
+ try {
372
+ doc = JSON.parse(raw);
373
+ } catch {
374
+ throw new LedgerError(
375
+ 'LEDGER_MALFORMED',
376
+ 'Ledger JSON is corrupted — paid calls are refused until a human reconciles',
377
+ { ledgerPath },
378
+ );
379
+ }
380
+ if (
381
+ doc === null ||
382
+ typeof doc !== 'object' ||
383
+ (doc.schema !== LEDGER_SCHEMA &&
384
+ doc.schema !== LEDGER_SCHEMA_V2 &&
385
+ doc.schema !== LEDGER_SCHEMA_V3)
386
+ )
387
+ throw new LedgerError(
388
+ 'LEDGER_SCHEMA_INVALID',
389
+ 'Unknown ledger schema — paid calls are refused',
390
+ {
391
+ ledgerPath,
392
+ schema: doc?.schema ?? null,
393
+ },
394
+ );
395
+ if (
396
+ !isFiniteNonNeg(doc.capUsd) ||
397
+ !Array.isArray(doc.entries) ||
398
+ typeof doc.updatedAt !== 'string'
399
+ )
400
+ throw new LedgerError('LEDGER_SCHEMA_INVALID', 'Required fields missing or ill-typed', {
401
+ ledgerPath,
402
+ });
403
+ // flags is a TRUST field (review finding E2): the reconciliation halt
404
+ // lives in it, so a ledger without it is not a valid ledger — otherwise
405
+ // deleting one field would reopen spending without a signed clearance.
406
+ if (
407
+ doc.flags === null ||
408
+ typeof doc.flags !== 'object' ||
409
+ typeof doc.flags.reconciliationRequired !== 'boolean'
410
+ )
411
+ throw new LedgerError(
412
+ 'LEDGER_SCHEMA_INVALID',
413
+ 'flags.reconciliationRequired missing or ill-typed',
414
+ {
415
+ ledgerPath,
416
+ },
417
+ );
418
+ const updatedAtMs = Date.parse(doc.updatedAt);
419
+ if (!Number.isFinite(updatedAtMs))
420
+ throw new LedgerError('LEDGER_SCHEMA_INVALID', 'updatedAt is not a timestamp', { ledgerPath });
421
+ // Campaign finding C-1 (fault injection, 2026-10-16): v1 is a CLOSED
422
+ // schema — a hashed, audited document has exactly one shape, and unknown
423
+ // top-level keys (including an own '__proto__' key smuggled through
424
+ // JSON.parse) are refused, not carried. Future fields mean a schema
425
+ // version bump, never silent extras.
426
+ // v2 REQUIRES the halt record: deleting it must not read as "not halted".
427
+ // v3 allows halt (present iff ever halted) and REQUIRES the archive summary,
428
+ // whose tip must match the real last cold segment (F-105) — a lost or
429
+ // altered cold trail is caught here, not read as freed cap.
430
+ if (doc.schema === LEDGER_SCHEMA_V2) validateHalt(doc.halt, ledgerPath);
431
+ if (doc.schema === LEDGER_SCHEMA_V3) {
432
+ if (doc.halt !== undefined) validateHalt(doc.halt, ledgerPath);
433
+ // the archive summary is checked for shape here — a corrupt or missing
434
+ // summary refuses, never reads as zero settled. Its binding to the cold
435
+ // trail (the head segment's running total, F-193) is checked by reserve
436
+ // and the seal, one segment read whatever the history; the rest of the
437
+ // chain (an older segment lost or altered) is `sakk ledger verify`.
438
+ try {
439
+ validateArchive(doc.archive, ledgerPath);
440
+ } catch (e) {
441
+ throw new LedgerError(
442
+ e.code ?? 'LEDGER_ARCHIVE_BROKEN',
443
+ e.message.replace(/^[A-Z_]+: /, ''),
444
+ {
445
+ ledgerPath,
446
+ ...(e.details ?? {}),
447
+ },
448
+ );
449
+ }
450
+ }
451
+ const topKeys =
452
+ doc.schema === LEDGER_SCHEMA_V3
453
+ ? LEDGER_TOP_KEYS_V3
454
+ : doc.schema === LEDGER_SCHEMA_V2
455
+ ? LEDGER_TOP_KEYS_V2
456
+ : LEDGER_TOP_KEYS_V1;
457
+ for (const k of Object.keys(doc))
458
+ if (!topKeys.includes(k))
459
+ throw new LedgerError('LEDGER_SCHEMA_INVALID', `unknown top-level key "${k}"`, {
460
+ ledgerPath,
461
+ key: k,
462
+ });
463
+ if (now - updatedAtMs > maxAgeMs)
464
+ throw new LedgerError(
465
+ 'LEDGER_STALE',
466
+ 'Ledger is older than the freshness bound — confirm it manually before reuse; stale books are not fresh books',
467
+ { ledgerPath, ageMs: now - updatedAtMs, maxAgeMs },
468
+ );
469
+ for (const e of doc.entries) {
470
+ if (
471
+ typeof e?.callId !== 'string' ||
472
+ !VALID_STATUS.has(e?.status) ||
473
+ !isFiniteNonNeg(e?.estimatedMaxUsd) ||
474
+ (e.status === 'SETTLED' && !isFiniteNonNeg(e?.actualUsd))
475
+ )
476
+ throw new LedgerError(
477
+ 'LEDGER_COST_UNCOMPUTABLE',
478
+ 'An entry has uncomputable cost — paid calls are refused until a human reconciles',
479
+ { ledgerPath, callId: e?.callId ?? null },
480
+ );
481
+ }
482
+ return doc;
483
+ }
484
+
485
+ /** Settled entries count at actuals; unsettled (including awaiting reconciliation) at their maximum — see header. */
486
+ const isHaltRecord = (r) =>
487
+ r !== null &&
488
+ typeof r === 'object' &&
489
+ !Array.isArray(r) &&
490
+ Object.keys(r).every((k) => ['by', 'at', 'reason'].includes(k)) &&
491
+ typeof r.by === 'string' &&
492
+ r.by.trim() !== '' &&
493
+ typeof r.at === 'string' &&
494
+ Number.isFinite(Date.parse(r.at)) &&
495
+ typeof r.reason === 'string';
496
+
497
+ /** A malformed halt record must never read as "not halted" (spec row 6). */
498
+ function validateHalt(h, ledgerPath) {
499
+ const bad = () => {
500
+ throw new LedgerError('LEDGER_SCHEMA_INVALID', 'halt record malformed', { ledgerPath });
501
+ };
502
+ if (h === null || typeof h !== 'object' || Array.isArray(h)) bad();
503
+ if (!Object.keys(h).every((k) => ['global', 'agents', 'lifts'].includes(k))) bad();
504
+ if (!(h.global === null || isHaltRecord(h.global))) bad();
505
+ if (h.agents === null || typeof h.agents !== 'object' || Array.isArray(h.agents)) bad();
506
+ for (const [k, r] of Object.entries(h.agents)) if (k === '' || !isHaltRecord(r)) bad();
507
+ if (!Array.isArray(h.lifts) || !h.lifts.every(isLiftRecord)) bad();
508
+ }
509
+
510
+ /** Who lifted which halt, kept in the books — the alert log is best-effort (review S2). */
511
+ const isLiftRecord = (r) =>
512
+ r !== null &&
513
+ typeof r === 'object' &&
514
+ !Array.isArray(r) &&
515
+ Object.keys(r).every((k) =>
516
+ ['by', 'at', 'scope', 'agentId', 'haltedBy', 'haltedAt'].includes(k),
517
+ ) &&
518
+ typeof r.by === 'string' &&
519
+ r.by.trim() !== '' &&
520
+ typeof r.at === 'string' &&
521
+ Number.isFinite(Date.parse(r.at)) &&
522
+ ((r.scope === 'global' && r.agentId === null) ||
523
+ (r.scope === 'agent' && typeof r.agentId === 'string' && r.agentId !== '')) &&
524
+ typeof r.haltedBy === 'string' &&
525
+ typeof r.haltedAt === 'string';
526
+
527
+ const emptyHalt = () => ({ global: null, agents: {}, lifts: [] });
528
+
529
+ /**
530
+ * Agent ids are caller data used as object keys: only OWN keys count, and
531
+ * writes define own properties. A plain `agents[id]` would read
532
+ * `constructor`/`toString` off the prototype as "halted", and assigning
533
+ * `agents['__proto__']` would swap the prototype instead of recording the
534
+ * halt — reported as success, never written to disk.
535
+ */
536
+ const agentHaltOf = (h, id) => (h && Object.hasOwn(h.agents, id) ? h.agents[id] : undefined);
537
+ const setAgentHalt = (h, id, record) =>
538
+ Object.defineProperty(h.agents, id, {
539
+ value: record,
540
+ enumerable: true,
541
+ writable: true,
542
+ configurable: true,
543
+ });
544
+
545
+ /**
546
+ * reserve: undefined/null = anonymous; anything else must be a non-empty
547
+ * string (a numeric id could never match a halt). halt/resume (`scope`):
548
+ * only undefined means "everyone" — a null from a failed id lookup must
549
+ * never widen a stop or a resume to all spending.
550
+ */
551
+ function checkAgentId(agentId, { scope = false, stored = true } = {}) {
552
+ if (agentId === undefined || (agentId === null && !scope)) return;
553
+ if (typeof agentId !== 'string' || agentId === '')
554
+ throw new LedgerError('AGENT_ID_INVALID', 'agentId must be a non-empty string when given', {
555
+ agentId: typeof agentId === 'string' ? agentId : typeof agentId,
556
+ });
557
+ // only an id that will be stored is bounded; one that is looked up (resume) is
558
+ // not, or a halt recorded before ids were bounded could never be lifted (F-150)
559
+ if (stored) checkLength('agentId', agentId, LIMITS.idChars);
560
+ }
561
+
562
+ export function totalsOf(doc) {
563
+ // the sealed cold trail counts at its summary; the gate never re-reads cold
564
+ // (F-105), so a long history costs no more than its hot tail
565
+ let settledUsd = doc.archive?.settledUsd ?? 0;
566
+ let outstandingUsd = 0;
567
+ for (const e of doc.entries) {
568
+ if (e.status === 'SETTLED') settledUsd += e.actualUsd;
569
+ else outstandingUsd += e.estimatedMaxUsd;
570
+ }
571
+ return {
572
+ settledUsd,
573
+ outstandingUsd,
574
+ committedUsd: settledUsd + outstandingUsd,
575
+ capUsd: doc.capUsd,
576
+ };
577
+ }
578
+
579
+ /**
580
+ * The largest amount `reserve` would accept right now, reported by `status`
581
+ * and the MCP totals. Headroom under the cap less the estimate floor: at that
582
+ * amount the cap check passes (the floor exceeds the cap epsilon by far) and
583
+ * the floor check passes. It is 0 whenever the headroom is below the floor —
584
+ * reporting a number the floor would refuse was a false promise (F-152).
585
+ */
586
+ export function maxReservableUsd(totals) {
587
+ const headroom = totals.capUsd - totals.committedUsd - LIMITS.minEstimateUsd;
588
+ return headroom >= LIMITS.minEstimateUsd ? headroom : 0;
589
+ }
590
+
591
+ /**
592
+ * What `sakk status --json` prints and the console's ledger page shows — one
593
+ * function, so the two cannot drift. `doc` is a ledger readLedger returned.
594
+ */
595
+ export function ledgerStatus({ ledgerPath, doc, now = Date.now() }) {
596
+ const t = totalsOf(doc);
597
+ const byStatus = { RESERVED: 0, SETTLED: 0, RECONCILIATION_REQUIRED: 0 };
598
+ for (const e of doc.entries) byStatus[e.status] += 1;
599
+ // the same two sources reserve() refuses on (review S2: status read one)
600
+ const reco =
601
+ doc.flags?.reconciliationRequired === true ||
602
+ doc.entries.some((x) => x.status === 'RECONCILIATION_REQUIRED');
603
+ const stale = now - Date.parse(doc.updatedAt) > DEFAULT_MAX_AGE_MS;
604
+ const blocked = Boolean(doc.halt?.global) || reco || stale;
605
+ return {
606
+ ledger: ledgerPath,
607
+ cap_usd: t.capUsd,
608
+ settled_usd: t.settledUsd,
609
+ outstanding_usd: t.outstandingUsd,
610
+ committed_usd: t.committedUsd,
611
+ remaining_usd: Math.max(0, t.capUsd - t.committedUsd),
612
+ // the largest amount a reserve() actually accepts: the cap is a wall, and
613
+ // an amount below the estimate floor is refused too (F-150). review S2: a
614
+ // figure that does not work when acted on is wrong
615
+ max_reservable_usd: blocked ? 0 : maxReservableUsd(t),
616
+ entries: byStatus,
617
+ reconciliation_required: reco,
618
+ reconciliation_reason: doc.flags?.reason ?? null,
619
+ halted: doc.halt?.global ?? null,
620
+ halted_agents: doc.halt?.agents ?? {},
621
+ updated_at: doc.updatedAt,
622
+ stale,
623
+ };
624
+ }
625
+
626
+ function writeLedger(ledgerPath, doc, { keepUpdatedAt = false } = {}) {
627
+ // updatedAt means "the books were last confirmed": a halt or resume
628
+ // confirms nothing, so it keeps the stamp — a stop must never reopen
629
+ // spending on a stale ledger (kill-switch review S1)
630
+ if (!keepUpdatedAt) doc.updatedAt = new Date().toISOString();
631
+ // The books must survive power loss and never install partial: the shared
632
+ // hardened writer (append.mjs) does the O_EXCL temp, short-write check,
633
+ // fsync and atomic rename; a reservation that vanishes after the paid call
634
+ // launched is the one corruption this module cannot tolerate. Its plain
635
+ // error becomes the typed LEDGER_WRITE_FAILED here.
636
+ try {
637
+ writeFileAtomic(ledgerPath, `${JSON.stringify(doc, null, 1)}\n`);
638
+ } catch (e) {
639
+ throw new LedgerError('LEDGER_WRITE_FAILED', `ledger write failed: ${e.code ?? e.message}`, {
640
+ ledgerPath,
641
+ cause: e.code ?? String(e.message).slice(0, 80),
642
+ });
643
+ }
644
+ }
645
+
646
+ /**
647
+ * Explicit initialization — the only way a ledger comes into existence.
648
+ * Refuses to overwrite: replacing a real ledger is indistinguishable from
649
+ * erasing the books.
650
+ */
651
+ export function initLedger({
652
+ ledgerPath = defaultLedgerPath(),
653
+ capUsd,
654
+ initializedBy = 'explicit-init',
655
+ }) {
656
+ checkLength('initializedBy', initializedBy, LIMITS.nameChars);
657
+ ledgerPath = canonicalLedgerPath(ledgerPath);
658
+ if (!isFiniteNonNeg(capUsd) || capUsd <= 0)
659
+ throw new LedgerError('LEDGER_INIT_INVALID', 'capUsd must be a positive number', { capUsd });
660
+ const lock = acquireLock(ledgerPath);
661
+ try {
662
+ if (existsSync(ledgerPath))
663
+ throw new LedgerError(
664
+ 'LEDGER_ALREADY_EXISTS',
665
+ 'Ledger already exists — init refuses to overwrite',
666
+ {
667
+ ledgerPath,
668
+ },
669
+ );
670
+ // a new ledger beside an old cold trail would read that trail's segments
671
+ // and index as its own: a fresh summary plus an old segment 1 is exactly
672
+ // what a rolled-back summary looks like (F-193 review)
673
+ for (const d of [coldDir(ledgerPath), idxDir(ledgerPath)])
674
+ if (existsSync(d))
675
+ throw new LedgerError(
676
+ 'LEDGER_ALREADY_EXISTS',
677
+ 'a cold trail from an earlier ledger is still here — move it away first',
678
+ { ledgerPath, dir: d },
679
+ );
680
+ writeLedger(ledgerPath, {
681
+ schema: LEDGER_SCHEMA,
682
+ initializedAt: new Date().toISOString(),
683
+ initializedBy,
684
+ capUsd,
685
+ flags: { reconciliationRequired: false, reason: null },
686
+ entries: [],
687
+ });
688
+ return { ok: true, ledgerPath };
689
+ } finally {
690
+ releaseLock(lock);
691
+ }
692
+ }
693
+
694
+ /**
695
+ * Reserve — the only lawful gate in front of a paid call.
696
+ * Returns {ok:true} or throws a LedgerError (refusal is an exception, never a silent no).
697
+ */
698
+ export function reserve({
699
+ ledgerPath = defaultLedgerPath(),
700
+ maxAgeMs = DEFAULT_MAX_AGE_MS,
701
+ runId,
702
+ taskId,
703
+ agentId,
704
+ callId,
705
+ estimatedMaxUsd,
706
+ now = Date.now(),
707
+ }) {
708
+ ledgerPath = canonicalLedgerPath(ledgerPath);
709
+ if (typeof callId !== 'string' || callId === '')
710
+ throw new LedgerError('CALL_ID_REQUIRED', 'Every potentially paid call needs a unique callId');
711
+ checkLength('callId', callId, LIMITS.idChars);
712
+ checkLength('runId', runId, LIMITS.idChars);
713
+ checkLength('taskId', taskId, LIMITS.idChars);
714
+ if (!isFiniteNonNeg(estimatedMaxUsd) || estimatedMaxUsd < LIMITS.minEstimateUsd)
715
+ throw new LedgerError(
716
+ 'ESTIMATE_INVALID',
717
+ `estimatedMaxUsd must be a number of at least ${LIMITS.minEstimateUsd}`,
718
+ { callId },
719
+ );
720
+ checkAgentId(agentId);
721
+ const lock = acquireLock(ledgerPath);
722
+ try {
723
+ const doc = readLedger({ ledgerPath, maxAgeMs, now });
724
+ // Kill switch first: a deliberate stop outranks every other check. The
725
+ // refusal is alerted so "did anything try to get through" is on record.
726
+ if (doc.halt?.global) {
727
+ emitAlert(ledgerPath, {
728
+ kind: 'HALT_REFUSED',
729
+ scope: 'global',
730
+ callId,
731
+ agentId: agentId ?? null,
732
+ });
733
+ throw new LedgerError('LEDGER_HALTED', `spending halted by ${doc.halt.global.by} — refused`, {
734
+ callId,
735
+ halt: doc.halt.global,
736
+ });
737
+ }
738
+ const agentHalt = typeof agentId === 'string' ? agentHaltOf(doc.halt, agentId) : undefined;
739
+ if (agentHalt) {
740
+ emitAlert(ledgerPath, { kind: 'HALT_REFUSED', scope: 'agent', callId, agentId });
741
+ throw new LedgerError(
742
+ 'AGENT_HALTED',
743
+ `agent ${agentId} halted by ${agentHalt.by} — refused`,
744
+ {
745
+ callId,
746
+ agentId,
747
+ halt: agentHalt,
748
+ },
749
+ );
750
+ }
751
+ // a seal that crashed before its hot commit is finished first, so the
752
+ // checks below see the books it committed to (F-193 review)
753
+ try {
754
+ adoptLeftover(ledgerPath, doc);
755
+ } catch (e) {
756
+ if (!(e instanceof ArchiveError)) throw e;
757
+ throw new LedgerError(e.code, e.message.replace(/^[A-Z_]+: /, ''), {
758
+ ledgerPath,
759
+ ...e.details,
760
+ });
761
+ }
762
+ // Dual-source halt (review finding E2): the flag OR any entry still in
763
+ // RECONCILIATION_REQUIRED blocks new spending — the halt must not hinge
764
+ // on a single deletable field.
765
+ if (
766
+ doc.flags?.reconciliationRequired === true ||
767
+ doc.entries.some((x) => x.status === 'RECONCILIATION_REQUIRED')
768
+ )
769
+ throw new LedgerError(
770
+ 'RECONCILIATION_REQUIRED',
771
+ 'Unresolved reconciliation — paid calls are refused until the books are squared',
772
+ { ledgerPath, reason: doc.flags?.reason ?? null },
773
+ );
774
+ const existing = doc.entries.find((e) => e.callId === callId);
775
+ if (existing !== undefined) {
776
+ /*
777
+ * Duplicate callIds are fail-closed, whatever the existing status.
778
+ * The soft alternative ("idempotent ok, bill once") lets the caller
779
+ * run the paid action a second time — deduplicated billing with
780
+ * re-executed side effects is exactly the shape of a money leak.
781
+ */
782
+ throw new LedgerError(
783
+ 'DUPLICATE_CALL_ID',
784
+ `callId already exists (status=${existing.status}) — a repeated call must not trigger the paid action again`,
785
+ { callId, existingStatus: existing.status },
786
+ );
787
+ }
788
+ // the index is the only cross-history duplicate guard; if the cold trail has
789
+ // sealed segments but the whole index is gone, dedup would silently fail —
790
+ // refuse rather than re-admit a sealed callId (F-105 review HIGH). A partial
791
+ // loss is caught by sakk ledger verify; sakk ledger reindex rebuilds it.
792
+ if ((doc.archive?.segments ?? 0) > 0 && !existsSync(idxDir(ledgerPath)))
793
+ throw new LedgerError(
794
+ 'LEDGER_ARCHIVE_BROKEN',
795
+ 'the call-id index is missing but the cold trail has sealed segments — run sakk ledger reindex',
796
+ { ledgerPath },
797
+ );
798
+ // the summary is the sealed part of the cap's total, and the hot file is
799
+ // writable: refuse unless it matches the head segment's hash-covered
800
+ // running total and nothing was sealed past it (F-193). One segment read.
801
+ try {
802
+ checkHead(ledgerPath, doc.archive ?? emptyArchive());
803
+ } catch (e) {
804
+ if (!(e instanceof ArchiveError)) throw e;
805
+ throw new LedgerError(e.code, e.message.replace(/^[A-Z_]+: /, ''), {
806
+ ledgerPath,
807
+ ...e.details,
808
+ });
809
+ }
810
+ // a callId sealed into the cold trail is used too — uniqueness holds across
811
+ // all history, not just the hot tail (F-105); one small index shard read
812
+ if (indexLookup(ledgerPath, callId) !== null)
813
+ throw new LedgerError(
814
+ 'DUPLICATE_CALL_ID',
815
+ `callId already exists (status=SETTLED, sealed) — a repeated call must not trigger the paid action again`,
816
+ { callId, existingStatus: 'SETTLED' },
817
+ );
818
+ // the only thing that can grow the hot file without bound: reservations that
819
+ // are never settled. Cap the outstanding count (F-105); settling still works.
820
+ const inflight = doc.entries.filter(
821
+ (e) => e.status === 'RESERVED' || e.status === 'RECONCILIATION_REQUIRED',
822
+ ).length;
823
+ if (inflight >= MAX_INFLIGHT)
824
+ throw new LedgerError(
825
+ 'LEDGER_FULL',
826
+ `${MAX_INFLIGHT} reservations are outstanding — settle what is open before reserving more`,
827
+ { ledgerPath, inflight },
828
+ );
829
+ const t = totalsOf(doc);
830
+ if (t.committedUsd + estimatedMaxUsd >= doc.capUsd - CAP_EPSILON_USD) {
831
+ emitAlert(ledgerPath, { kind: 'BUDGET_REFUSED', callId, estimatedMaxUsd, ...t });
832
+ throw new LedgerError(
833
+ 'BUDGET_EXCEEDED',
834
+ 'settled + outstanding + this estimate reaches the cap — refused (the cap is a wall, not a target)',
835
+ { callId, estimatedMaxUsd, ...t },
836
+ );
837
+ }
838
+ doc.entries.push({
839
+ callId,
840
+ runId: runId ?? null,
841
+ taskId: taskId ?? null,
842
+ agentId: agentId ?? null,
843
+ estimatedMaxUsd,
844
+ createdAt: new Date().toISOString(),
845
+ status: 'RESERVED',
846
+ });
847
+ writeLedger(ledgerPath, doc);
848
+ return { ok: true, duplicate: false, callId };
849
+ } finally {
850
+ releaseLock(lock);
851
+ }
852
+ }
853
+
854
+ /**
855
+ * Settle. On success pass actualUsd (actuals replace the reservation,
856
+ * releasing the difference). If the action failed and the real cost is
857
+ * unknowable, pass failedUnknownCost:true — the entry enters
858
+ * RECONCILIATION_REQUIRED and a global flag blocks all further paid calls.
859
+ * Settling an already-settled callId is an idempotent no-op.
860
+ */
861
+ export function settle({
862
+ ledgerPath = defaultLedgerPath(),
863
+ callId,
864
+ actualUsd,
865
+ tokens = null,
866
+ failedUnknownCost = false,
867
+ allowReconciling = true,
868
+ }) {
869
+ ledgerPath = canonicalLedgerPath(ledgerPath);
870
+ // the same id rule as reserve: a non-string id is refused, never searched
871
+ // for and echoed back (F-82 — an echoed nested value crashed the MCP gate)
872
+ if (typeof callId !== 'string' || callId === '')
873
+ throw new LedgerError('CALL_ID_REQUIRED', 'Settling needs the callId the reservation used');
874
+ // no length bound here (F-150): settle stores no new id, it looks one up, and a
875
+ // reservation made before ids were bounded must still be settled, or its
876
+ // estimate holds the cap for ever
877
+ const lock = acquireLock(ledgerPath);
878
+ try {
879
+ const doc = readLedger({ ledgerPath, maxAgeMs: Number.POSITIVE_INFINITY });
880
+ // finish a crashed seal first, so a retried settle of a call it sealed is
881
+ // answered from the segment; a leftover that is not sound stays on disk,
882
+ // the settle is still recorded hot, and reserve refuses (F-193 review)
883
+ try {
884
+ adoptLeftover(ledgerPath, doc);
885
+ } catch (err) {
886
+ if (!(err instanceof ArchiveError)) throw err;
887
+ }
888
+ const e = doc.entries.find((x) => x.callId === callId);
889
+ if (e === undefined) {
890
+ // a callId already sealed into the cold trail is a settled entry: a
891
+ // re-settle is idempotent for the identical amount, a mismatch otherwise,
892
+ // and it is never un-sealed back into the hot file (F-105)
893
+ const sealed = archivedEntry(ledgerPath, callId);
894
+ if (sealed !== null) {
895
+ if (
896
+ failedUnknownCost === true ||
897
+ (actualUsd !== undefined && actualUsd !== sealed.actualUsd)
898
+ ) {
899
+ emitAlert(ledgerPath, {
900
+ kind: 'SETTLE_MISMATCH',
901
+ callId,
902
+ recordedUsd: sealed.actualUsd,
903
+ attemptedUsd: failedUnknownCost ? null : actualUsd,
904
+ });
905
+ throw new LedgerError(
906
+ 'SETTLE_MISMATCH',
907
+ 'Re-settling a sealed entry with a different outcome than recorded — refused',
908
+ { callId, recordedUsd: sealed.actualUsd },
909
+ );
910
+ }
911
+ return { ok: true, duplicate: true, callId };
912
+ }
913
+ throw new LedgerError('SETTLE_UNKNOWN_CALL', 'Settling a callId that was never reserved', {
914
+ callId,
915
+ });
916
+ }
917
+ if (e.status === 'SETTLED') {
918
+ // Idempotent ONLY for the identical outcome. A different amount (or a
919
+ // late failedUnknownCost) silently swallowed would mask an accounting
920
+ // bug — the books do not bend.
921
+ if (failedUnknownCost === true || (actualUsd !== undefined && actualUsd !== e.actualUsd)) {
922
+ emitAlert(ledgerPath, {
923
+ kind: 'SETTLE_MISMATCH',
924
+ callId,
925
+ recordedUsd: e.actualUsd,
926
+ attemptedUsd: failedUnknownCost ? null : actualUsd,
927
+ });
928
+ throw new LedgerError(
929
+ 'SETTLE_MISMATCH',
930
+ 'Re-settling with a different outcome than recorded — refused',
931
+ { callId, recordedUsd: e.actualUsd },
932
+ );
933
+ }
934
+ return { ok: true, duplicate: true, callId };
935
+ }
936
+ // An entry awaiting reconciliation is resolved by a person; over MCP the
937
+ // agent that caused it could otherwise settle it at $0 itself (MCP review N8)
938
+ if (e.status === 'RECONCILIATION_REQUIRED' && !allowReconciling)
939
+ throw new LedgerError(
940
+ 'SETTLE_RECONCILIATION_HUMAN_ONLY',
941
+ 'this entry awaits reconciliation by a person — it cannot be settled through this channel',
942
+ { callId },
943
+ );
944
+ if (failedUnknownCost === true) {
945
+ e.status = 'RECONCILIATION_REQUIRED';
946
+ doc.flags = {
947
+ reconciliationRequired: true,
948
+ reason: boundReason(`call ${callId} failed with unknowable actual cost`),
949
+ };
950
+ emitAlert(ledgerPath, { kind: 'RECONCILIATION_REQUIRED', callId });
951
+ writeLedger(ledgerPath, doc, { keepUpdatedAt: true });
952
+ return { ok: true, reconciliationRequired: true, callId };
953
+ }
954
+ if (!isFiniteNonNeg(actualUsd))
955
+ throw new LedgerError(
956
+ 'SETTLE_INVALID',
957
+ 'actualUsd must be a non-negative number (if unknowable, use failedUnknownCost)',
958
+ { callId },
959
+ );
960
+ // tokens is telemetry the caller supplies; bound its stored size (F-150) and
961
+ // keep only a plain object or null, never an attacker-sized blob
962
+ if (tokens !== null && tokens !== undefined) {
963
+ if (typeof tokens !== 'object' || Array.isArray(tokens))
964
+ throw new LedgerError('INPUT_INVALID', 'tokens must be an object or null', { callId });
965
+ if (JSON.stringify(tokens).length > LIMITS.tokensBytes)
966
+ throw new LedgerError(
967
+ 'INPUT_TOO_LONG',
968
+ `tokens is larger than ${LIMITS.tokensBytes} bytes`,
969
+ {
970
+ callId,
971
+ },
972
+ );
973
+ }
974
+ e.status = 'SETTLED';
975
+ e.actualUsd = actualUsd;
976
+ e.tokens = tokens ?? null;
977
+ e.settledAt = new Date().toISOString();
978
+ if (actualUsd > e.estimatedMaxUsd + 1e-9) {
979
+ /*
980
+ * Overrun (review finding E1): the actual cost exceeded the reserved
981
+ * maximum — the one event that proves the pre-spend gate was pierced.
982
+ * The money is spent, so it is RECORDED (refusing would only make the
983
+ * books wrong), but it must never pass in silence: alert + global
984
+ * reconciliation halt until a named human squares the books.
985
+ */
986
+ emitAlert(ledgerPath, {
987
+ kind: 'BUDGET_OVERRUN',
988
+ callId,
989
+ reservedUsd: e.estimatedMaxUsd,
990
+ actualUsd,
991
+ capUsd: doc.capUsd,
992
+ });
993
+ doc.flags = {
994
+ reconciliationRequired: true,
995
+ reason: boundReason(
996
+ `call ${callId} settled ${actualUsd} against a ${e.estimatedMaxUsd} reservation`,
997
+ ),
998
+ };
999
+ }
1000
+ // once settled entries fill the hot tail, seal the oldest into the cold
1001
+ // trail so the live file stays bounded (F-105). The seal writes the
1002
+ // segment and index first, then this write commits the smaller hot file:
1003
+ // a crash before it leaves a segment the next reserve or settle adopts as
1004
+ // written, not a lost entry.
1005
+ maybeSeal(ledgerPath, doc);
1006
+ // a settle confirms nothing about the books: an agent settling an old
1007
+ // reservation must not make a stale ledger fresh (review S1, F-67 class)
1008
+ writeLedger(ledgerPath, doc, { keepUpdatedAt: true });
1009
+ return { ok: true, duplicate: false, callId };
1010
+ } finally {
1011
+ releaseLock(lock);
1012
+ }
1013
+ }
1014
+
1015
+ /**
1016
+ * Seal the oldest settled entries when the hot tail reaches SEAL_AT, in place
1017
+ * on `doc` (the caller writes it). Runs under the ledger lock, so there is no
1018
+ * concurrent sealer. Upgrades a v1/v2 ledger to v3 the first time.
1019
+ */
1020
+ function maybeSeal(ledgerPath, doc) {
1021
+ const settled = doc.entries.filter((e) => e.status === 'SETTLED');
1022
+ if (settled.length < SEAL_AT) return;
1023
+ const archive = doc.archive ?? emptyArchive();
1024
+ // seal exactly SEAL_AT oldest settled (array order is reservation order),
1025
+ // leaving the rest hot; segments are uniform at SEAL_AT entries
1026
+ const toSeal = settled.slice(0, SEAL_AT);
1027
+ const sealedIds = new Set(toSeal.map((e) => e.callId));
1028
+ // the running total through this segment, hash-covered: the summary must equal it
1029
+ const total = {
1030
+ settledCount: archive.settledCount + toSeal.length,
1031
+ settledUsd: archive.settledUsd + toSeal.reduce((s, e) => s + e.actualUsd, 0),
1032
+ };
1033
+ // never extend a trail the summary does not match (a new head would carry
1034
+ // an edit and launder it, F-193), and never write a segment number already
1035
+ // on disk (sealSegment refuses). A refused seal leaves the entries hot, so
1036
+ // they still count, and reserve refuses until a person repairs the trail.
1037
+ let sealed;
1038
+ try {
1039
+ checkHead(ledgerPath, archive);
1040
+ sealed = sealSegment(ledgerPath, archive.segments, archive.tip, toSeal, total);
1041
+ } catch (e) {
1042
+ if (e instanceof ArchiveError) return;
1043
+ throw e;
1044
+ }
1045
+ doc.schema = LEDGER_SCHEMA_V3;
1046
+ doc.archive = { segments: sealed.seq, ...total, tip: sealed.hash };
1047
+ doc.entries = doc.entries.filter((e) => !(e.status === 'SETTLED' && sealedIds.has(e.callId)));
1048
+ }
1049
+
1050
+ /**
1051
+ * Finish a seal that crashed before its hot commit (F-193 review): the
1052
+ * segment it left is adopted as written — the summary moves to it, its
1053
+ * entries leave hot and its call-ids are indexed (again, if the crash came
1054
+ * after the shards). Its number is never sealed a second time, so no edit of
1055
+ * the hot file can change what it says. An overrun the crashed settle would
1056
+ * have flagged is flagged here. Writes `doc` when it adopts; throws
1057
+ * ArchiveError when the leftover is not a clean continuation.
1058
+ */
1059
+ function adoptLeftover(ledgerPath, doc) {
1060
+ const archive = doc.archive ?? emptyArchive();
1061
+ const left = leftoverSegment(ledgerPath, archive, doc.entries);
1062
+ if (left === null) return;
1063
+ // indexing would recreate a lost index and hide the loss (F-105 review)
1064
+ if (archive.segments > 0 && !existsSync(idxDir(ledgerPath)))
1065
+ throw new ArchiveError(
1066
+ 'LEDGER_ARCHIVE_BROKEN',
1067
+ 'the call-id index is missing but the cold trail has sealed segments — run sakk ledger reindex',
1068
+ { ledgerPath },
1069
+ );
1070
+ indexSegment(ledgerPath, left.archive.segments, left.entries);
1071
+ const sealed = new Map(left.entries.map((e) => [e.callId, e]));
1072
+ for (const h of doc.entries) {
1073
+ const s = sealed.get(h.callId);
1074
+ if (s === undefined || h.status === 'SETTLED' || !(s.actualUsd > h.estimatedMaxUsd + 1e-9))
1075
+ continue;
1076
+ emitAlert(ledgerPath, {
1077
+ kind: 'BUDGET_OVERRUN',
1078
+ callId: h.callId,
1079
+ reservedUsd: h.estimatedMaxUsd,
1080
+ actualUsd: s.actualUsd,
1081
+ capUsd: doc.capUsd,
1082
+ });
1083
+ doc.flags = {
1084
+ reconciliationRequired: true,
1085
+ reason: boundReason(
1086
+ `call ${h.callId} settled ${s.actualUsd} against a ${h.estimatedMaxUsd} reservation`,
1087
+ ),
1088
+ };
1089
+ }
1090
+ doc.schema = LEDGER_SCHEMA_V3;
1091
+ doc.archive = left.archive;
1092
+ doc.entries = doc.entries.filter((e) => !sealed.has(e.callId));
1093
+ writeLedger(ledgerPath, doc, { keepUpdatedAt: true });
1094
+ }
1095
+
1096
+ /** For out-of-band accounting failures: completed work stands, but the flag + alert block all further paid calls. */
1097
+ export function markReconciliationRequired({ ledgerPath = defaultLedgerPath(), reason }) {
1098
+ ledgerPath = canonicalLedgerPath(ledgerPath);
1099
+ const lock = acquireLock(ledgerPath);
1100
+ try {
1101
+ const doc = readLedger({ ledgerPath, maxAgeMs: Number.POSITIVE_INFINITY });
1102
+ doc.flags = { reconciliationRequired: true, reason: boundReason(reason) };
1103
+ emitAlert(ledgerPath, { kind: 'RECONCILIATION_REQUIRED', reason: doc.flags.reason });
1104
+ writeLedger(ledgerPath, doc);
1105
+ return { ok: true };
1106
+ } finally {
1107
+ releaseLock(lock);
1108
+ }
1109
+ }
1110
+
1111
+ /**
1112
+ * Kill switch: stop spending on this ledger, globally or for one agent id
1113
+ * (exact, case-sensitive match on the id the agent reserves under).
1114
+ * Reads with infinite staleness and ignores reconciliation state — a stop
1115
+ * must never be blocked by the conditions it exists for. First halt wins:
1116
+ * halting an already-halted scope keeps the original record. The first
1117
+ * halt upgrades the ledger to v2 (see LEDGER_SCHEMA_V2).
1118
+ */
1119
+ export function halt({ ledgerPath = defaultLedgerPath(), by, reason = '', agentId }) {
1120
+ ledgerPath = canonicalLedgerPath(ledgerPath);
1121
+ // blank names refused here, or the record would fail its own read check
1122
+ if (typeof by !== 'string' || by.trim() === '')
1123
+ throw new LedgerError('HALT_BY_REQUIRED', 'Halting requires a name');
1124
+ checkLength('by', by, LIMITS.nameChars);
1125
+ checkLength('reason', typeof reason === 'string' ? reason : String(reason), LIMITS.reasonChars);
1126
+ checkAgentId(agentId, { scope: true });
1127
+ const scoped = typeof agentId === 'string';
1128
+ const lock = acquireLock(ledgerPath);
1129
+ try {
1130
+ const doc = readLedger({ ledgerPath, maxAgeMs: Number.POSITIVE_INFINITY });
1131
+ const h = doc.halt ?? emptyHalt();
1132
+ const existing = scoped ? agentHaltOf(h, agentId) : h.global;
1133
+ if (existing) return { ok: true, alreadyHalted: true, halt: existing };
1134
+ const record = { by, at: new Date().toISOString(), reason: String(reason) };
1135
+ if (scoped) setAgentHalt(h, agentId, record);
1136
+ else h.global = record;
1137
+ // a halt makes the ledger at least v2; a ledger that already seals to the
1138
+ // cold trail stays v3 (never downgrade and strand its archive key)
1139
+ doc.schema = doc.archive ? LEDGER_SCHEMA_V3 : LEDGER_SCHEMA_V2;
1140
+ doc.halt = h;
1141
+ writeLedger(ledgerPath, doc, { keepUpdatedAt: true });
1142
+ emitAlert(ledgerPath, {
1143
+ kind: 'HALT_SET',
1144
+ scope: scoped ? 'agent' : 'global',
1145
+ agentId: scoped ? agentId : null,
1146
+ ...record,
1147
+ });
1148
+ return { ok: true, alreadyHalted: false, halt: record };
1149
+ } finally {
1150
+ releaseLock(lock);
1151
+ }
1152
+ }
1153
+
1154
+ /**
1155
+ * Lift a halt. Requires a name; reports honestly when nothing was halted.
1156
+ * The ledger stays v2, and `halt.lifts` keeps who lifted which halt and
1157
+ * when — in the books, not only in the best-effort alert log.
1158
+ */
1159
+ export function resume({ ledgerPath = defaultLedgerPath(), by, agentId }) {
1160
+ ledgerPath = canonicalLedgerPath(ledgerPath);
1161
+ // blank names refused here, or the record would fail its own read check
1162
+ if (typeof by !== 'string' || by.trim() === '')
1163
+ throw new LedgerError('RESUME_BY_REQUIRED', 'Resuming requires a name');
1164
+ checkLength('by', by, LIMITS.nameChars);
1165
+ checkAgentId(agentId, { scope: true, stored: false });
1166
+ const scoped = typeof agentId === 'string';
1167
+ const lock = acquireLock(ledgerPath);
1168
+ try {
1169
+ const doc = readLedger({ ledgerPath, maxAgeMs: Number.POSITIVE_INFINITY });
1170
+ const h = doc.halt;
1171
+ const existing = scoped ? agentHaltOf(h, agentId) : h?.global;
1172
+ if (!existing) return { ok: true, wasHalted: false };
1173
+ if (scoped) delete h.agents[agentId];
1174
+ else h.global = null;
1175
+ const lift = {
1176
+ by,
1177
+ at: new Date().toISOString(),
1178
+ scope: scoped ? 'agent' : 'global',
1179
+ agentId: scoped ? agentId : null,
1180
+ haltedBy: existing.by,
1181
+ haltedAt: existing.at,
1182
+ };
1183
+ h.lifts.push(lift);
1184
+ writeLedger(ledgerPath, doc, { keepUpdatedAt: true });
1185
+ emitAlert(ledgerPath, {
1186
+ kind: 'HALT_CLEARED',
1187
+ scope: lift.scope,
1188
+ agentId: lift.agentId,
1189
+ by,
1190
+ at: lift.at,
1191
+ haltedBy: existing.by,
1192
+ });
1193
+ return { ok: true, wasHalted: true, lifted: existing };
1194
+ } finally {
1195
+ releaseLock(lock);
1196
+ }
1197
+ }
1198
+
1199
+ /** Explicit, signed clearance after a human squares the books. Entries are never deleted — history is evidence. */
1200
+ export function clearReconciliation({ ledgerPath = defaultLedgerPath(), by }) {
1201
+ ledgerPath = canonicalLedgerPath(ledgerPath);
1202
+ if (typeof by !== 'string' || by.trim() === '')
1203
+ throw new LedgerError('RECONCILE_BY_REQUIRED', 'Clearing the flag requires a name');
1204
+ checkLength('by', by, LIMITS.nameChars);
1205
+ const lock = acquireLock(ledgerPath);
1206
+ try {
1207
+ const doc = readLedger({ ledgerPath, maxAgeMs: Number.POSITIVE_INFINITY });
1208
+ // Squaring the books means resolving the entries, not just signing the
1209
+ // flag away (pairs with the dual-source halt, finding E2): every
1210
+ // RECONCILIATION_REQUIRED entry must be settled first.
1211
+ const open = doc.entries.filter((x) => x.status === 'RECONCILIATION_REQUIRED');
1212
+ if (open.length > 0)
1213
+ throw new LedgerError(
1214
+ 'RECONCILIATION_ENTRIES_OPEN',
1215
+ 'Settle every RECONCILIATION_REQUIRED entry before clearing the flag',
1216
+ { callIds: open.map((x) => x.callId) },
1217
+ );
1218
+ doc.flags = {
1219
+ reconciliationRequired: false,
1220
+ reason: null,
1221
+ lastReconciledBy: by,
1222
+ lastReconciledAt: new Date().toISOString(),
1223
+ };
1224
+ writeLedger(ledgerPath, doc);
1225
+ return { ok: true };
1226
+ } finally {
1227
+ releaseLock(lock);
1228
+ }
1229
+ }