@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/.claude-plugin/marketplace.json +19 -0
- package/.claude-plugin/plugin.json +27 -0
- package/CHANGELOG.md +1421 -0
- package/LICENSE +202 -0
- package/NOTICE +82 -0
- package/README.md +620 -2
- package/README.zh-CN.md +555 -0
- package/bin/sakk.mjs +1460 -0
- package/integrations/litellm/LICENSE +202 -0
- package/integrations/litellm/NOTICE +82 -0
- package/integrations/litellm/README.md +366 -0
- package/integrations/litellm/config.example.yaml +52 -0
- package/integrations/litellm/sakk_gateway.py +823 -0
- package/package.json +88 -4
- package/policies/guard-default.json +161 -0
- package/policies/permissive-dev.json +22 -0
- package/prices/snapshot.json +1404 -0
- package/src/append.mjs +81 -0
- package/src/archive.mjs +489 -0
- package/src/classify.mjs +914 -0
- package/src/console-login.mjs +374 -0
- package/src/console.mjs +1628 -0
- package/src/gateway-status.mjs +203 -0
- package/src/guard-install.mjs +387 -0
- package/src/hook.mjs +730 -0
- package/src/ledger.mjs +1229 -0
- package/src/match.mjs +52 -0
- package/src/mcp.mjs +299 -0
- package/src/policy.mjs +423 -0
- package/src/prices.mjs +152 -0
- package/src/secret-rules.generated.mjs +225 -0
- package/src/secret-scan.mjs +489 -0
- package/src/spend.mjs +443 -0
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
|
+
}
|