@sakk-dev/sakk 0.0.0 → 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/append.mjs ADDED
@@ -0,0 +1,81 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ //
3
+ // Shared by the ledger (alerts) and the policy kernel (decision log), which
4
+ // must not import each other.
5
+
6
+ import {
7
+ closeSync,
8
+ constants as fsc,
9
+ fsyncSync,
10
+ openSync,
11
+ renameSync,
12
+ rmSync,
13
+ writeSync,
14
+ } from 'node:fs';
15
+ import { dirname } from 'node:path';
16
+ import { randomBytes } from 'node:crypto';
17
+
18
+ /** Append without following a symlink planted at a guessable name (F15 class, review S4). */
19
+ export function appendNoFollow(path, text) {
20
+ const fd = openSync(
21
+ path,
22
+ fsc.O_WRONLY | fsc.O_APPEND | fsc.O_CREAT | (fsc.O_NOFOLLOW ?? 0),
23
+ 0o600,
24
+ );
25
+ try {
26
+ const buf = Buffer.from(text);
27
+ let w = 0;
28
+ while (w < buf.length) w += writeSync(fd, buf, w, buf.length - w);
29
+ } finally {
30
+ closeSync(fd);
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Durable, replace-free file write for the ledger and its cold trail: an
36
+ * unguessable O_EXCL temp (no write THROUGH a planted symlink), every byte
37
+ * accounted for (writeSync can short-write without throwing — a SHORT_WRITE
38
+ * error rather than a truncated file installed), fsync before the atomic
39
+ * rename, then a best-effort directory fsync so the rename itself survives
40
+ * power loss. Throws a plain Error (callers wrap it in their own typed code).
41
+ */
42
+ export function writeFileAtomic(path, text, mode = 0o666) {
43
+ const tmp = `${path}.tmp-${process.pid}-${randomBytes(6).toString('hex')}`;
44
+ try {
45
+ const fd = openSync(tmp, 'wx', mode); // the umask can only narrow it
46
+ try {
47
+ const buf = Buffer.from(text);
48
+ let written = 0;
49
+ while (written < buf.length) {
50
+ const w = writeSync(fd, buf, written, buf.length - written);
51
+ if (!(w > 0)) {
52
+ const e = new Error('write made no progress — refusing to install a partial file');
53
+ e.code = 'SHORT_WRITE';
54
+ throw e;
55
+ }
56
+ written += w;
57
+ }
58
+ fsyncSync(fd);
59
+ } finally {
60
+ closeSync(fd);
61
+ }
62
+ renameSync(tmp, path);
63
+ } catch (e) {
64
+ try {
65
+ rmSync(tmp, { force: true });
66
+ } catch {
67
+ // best effort: a dying disk must not replace the real error with a cleanup one
68
+ }
69
+ throw e;
70
+ }
71
+ try {
72
+ const dfd = openSync(dirname(path), 'r');
73
+ try {
74
+ fsyncSync(dfd);
75
+ } finally {
76
+ closeSync(dfd);
77
+ }
78
+ } catch {
79
+ /* directory fsync is best effort — not every platform allows it */
80
+ }
81
+ }
@@ -0,0 +1,489 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ //
3
+ // The ledger's cold trail (F-105, specs/ledger "The hot/cold split"). Settled
4
+ // entries are sealed into append-only, hash-chained segments so the live
5
+ // `ledger.json` stays small and a call costs the same whatever the history.
6
+ // The gate's go/no-go reads the hot file's atomic `archive` summary, bound
7
+ // to the head segment: each sealed segment carries the running totals through
8
+ // it, under its hash, and reserve checks the summary against that one head
9
+ // (F-193). Cold is otherwise the audit record: a lost or altered older
10
+ // segment is caught by `sakk ledger verify`, never read as freed cap.
11
+ //
12
+ // All callers hold the ledger's mkdir lock, so there is never a concurrent
13
+ // sealer; this module does no locking of its own.
14
+
15
+ import { createHash } from 'node:crypto';
16
+ import { existsSync, mkdirSync, readFileSync, readdirSync } from 'node:fs';
17
+ import { join } from 'node:path';
18
+ import { writeFileAtomic } from './append.mjs';
19
+
20
+ /**
21
+ * Settled entries kept in the hot file before the oldest are sealed. A
22
+ * perf-tuning knob (SAKK_SEAL_AT) with a 1000 default: lowering it seals more
23
+ * often, raising it keeps more hot (still bounded by MAX_INFLIGHT). Not a
24
+ * safety limit — the cap and the chain do not depend on its value.
25
+ */
26
+ export const SEAL_AT =
27
+ Number(process.env.SAKK_SEAL_AT) > 0 ? Number(process.env.SAKK_SEAL_AT) : 1000;
28
+ /**
29
+ * Most reservations outstanding (RESERVED or awaiting reconciliation) at once
30
+ * (F-105, owner 2026-10-22). A hard constant, not env-tunable: it is the one
31
+ * thing that bounds the hot file, so it must not be loosened at run time.
32
+ */
33
+ export const MAX_INFLIGHT = 10_000;
34
+
35
+ export class ArchiveError extends Error {
36
+ constructor(code, message, details = {}) {
37
+ super(`${code}: ${message}`);
38
+ this.code = code;
39
+ this.details = details;
40
+ }
41
+ }
42
+
43
+ export const coldDir = (ledgerPath) => `${ledgerPath}.cold`;
44
+ export const idxDir = (ledgerPath) => `${ledgerPath}.idx`;
45
+ const segName = (seq) => `${String(seq).padStart(6, '0')}.json`;
46
+ const segPath = (ledgerPath, seq) => join(coldDir(ledgerPath), segName(seq));
47
+ const sha = (s) => createHash('sha256').update(s).digest('hex');
48
+ // 3 hex = 4096 shards, so a shard holds about (sealed call-ids / 4096): a
49
+ // call-id lookup stays small across the realistic range (hundreds of
50
+ // thousands to low millions of calls). Deeper sharding is the knob if a
51
+ // deployment ever seals far more than that.
52
+ const SHARD_HEX = 3;
53
+ const shardOf = (callId) => sha(callId).slice(0, SHARD_HEX);
54
+ const shardPath = (ledgerPath, callId) => join(idxDir(ledgerPath), `${shardOf(callId)}.json`);
55
+
56
+ /**
57
+ * The sealed hash of a segment: its seq, the previous segment's hash, its
58
+ * entries and, from F-193 on, the running totals through it. A segment sealed
59
+ * before F-193 has no `total` and hashes as it always did.
60
+ */
61
+ export function segmentHash(seq, prev, entries, total) {
62
+ return sha(
63
+ JSON.stringify(total === undefined ? { seq, prev, entries } : { seq, prev, entries, total }),
64
+ );
65
+ }
66
+
67
+ /** A segment's running total is exactly {settledCount, settledUsd}, both finite and non-negative. */
68
+ const validTotal = (t) =>
69
+ t !== null &&
70
+ typeof t === 'object' &&
71
+ !Array.isArray(t) &&
72
+ Object.keys(t).sort().join(',') === 'settledCount,settledUsd' &&
73
+ Number.isInteger(t.settledCount) &&
74
+ t.settledCount >= 0 &&
75
+ typeof t.settledUsd === 'number' &&
76
+ Number.isFinite(t.settledUsd) &&
77
+ t.settledUsd >= 0;
78
+
79
+ /** The empty summary a v1/v2 ledger (no cold trail yet) reads as. */
80
+ export const emptyArchive = () => ({ segments: 0, settledCount: 0, settledUsd: 0, tip: '' });
81
+
82
+ /** An archive summary has exactly the right shape and non-negative counts; a tip iff there are segments. */
83
+ export function validateArchive(archive, ledgerPath) {
84
+ const bad = (why) => {
85
+ throw new ArchiveError('LEDGER_ARCHIVE_BROKEN', why, { ledgerPath });
86
+ };
87
+ if (archive === null || typeof archive !== 'object' || Array.isArray(archive))
88
+ bad('archive is not an object');
89
+ const keys = Object.keys(archive).sort().join(',');
90
+ if (keys !== 'segments,settledCount,settledUsd,tip') bad('archive has the wrong keys');
91
+ if (!Number.isInteger(archive.segments) || archive.segments < 0) bad('archive.segments invalid');
92
+ if (!Number.isInteger(archive.settledCount) || archive.settledCount < 0)
93
+ bad('archive.settledCount invalid');
94
+ if (
95
+ typeof archive.settledUsd !== 'number' ||
96
+ !Number.isFinite(archive.settledUsd) ||
97
+ archive.settledUsd < 0
98
+ )
99
+ bad('archive.settledUsd invalid');
100
+ if (typeof archive.tip !== 'string') bad('archive.tip invalid');
101
+ if (archive.segments === 0 && archive.tip !== '') bad('no segments but a tip');
102
+ if (archive.segments > 0 && archive.tip === '') bad('segments but no tip');
103
+ }
104
+
105
+ /** Read and hash-check one segment; throws LEDGER_ARCHIVE_BROKEN if missing or altered. */
106
+ export function readSegment(ledgerPath, seq) {
107
+ let seg;
108
+ try {
109
+ seg = JSON.parse(readFileSync(segPath(ledgerPath, seq), 'utf8'));
110
+ } catch (e) {
111
+ throw new ArchiveError(
112
+ 'LEDGER_ARCHIVE_BROKEN',
113
+ `cold segment ${seq} is missing or unreadable`,
114
+ {
115
+ seq,
116
+ why: e.code ?? String(e.message).slice(0, 80),
117
+ },
118
+ );
119
+ }
120
+ if (
121
+ seg === null ||
122
+ typeof seg !== 'object' ||
123
+ seg.seq !== seq ||
124
+ typeof seg.prev !== 'string' ||
125
+ !Array.isArray(seg.entries) ||
126
+ (seg.total !== undefined && !validTotal(seg.total)) ||
127
+ seg.hash !== segmentHash(seq, seg.prev, seg.entries, seg.total)
128
+ )
129
+ throw new ArchiveError('LEDGER_ARCHIVE_BROKEN', `cold segment ${seq} fails its hash`, { seq });
130
+ return seg;
131
+ }
132
+
133
+ /**
134
+ * Walk the whole chain: every segment reads, every prev links, and the
135
+ * recomputed counts and total equal the summary. Returns the recomputed
136
+ * totals. Used by `sakk ledger verify`.
137
+ */
138
+ export function verifyChain(ledgerPath, archive) {
139
+ validateArchive(archive, ledgerPath);
140
+ let prev = '';
141
+ let settledCount = 0;
142
+ let settledUsd = 0;
143
+ const expectedIndex = new Map(); // shard -> { callId: seq }, to check the index too
144
+ for (let seq = 1; seq <= archive.segments; seq++) {
145
+ const seg = readSegment(ledgerPath, seq);
146
+ if (seg.prev !== prev)
147
+ throw new ArchiveError(
148
+ 'LEDGER_ARCHIVE_BROKEN',
149
+ `cold segment ${seq} does not link to ${seq - 1}`,
150
+ { seq },
151
+ );
152
+ for (const e of seg.entries) {
153
+ if (
154
+ e?.status !== 'SETTLED' ||
155
+ typeof e.actualUsd !== 'number' ||
156
+ !Number.isFinite(e.actualUsd)
157
+ )
158
+ throw new ArchiveError(
159
+ 'LEDGER_ARCHIVE_BROKEN',
160
+ `cold segment ${seq} has a non-settled entry`,
161
+ { seq },
162
+ );
163
+ const s = shardOf(e.callId);
164
+ if (!expectedIndex.has(s)) expectedIndex.set(s, {});
165
+ expectedIndex.get(s)[e.callId] = seq;
166
+ }
167
+ settledCount += seg.entries.length;
168
+ // sum per segment then add, exactly as maybeSeal accumulates archive.settledUsd,
169
+ // so float addition's non-associativity never makes an intact chain mismatch (F-105 review)
170
+ settledUsd += seg.entries.reduce((s, e) => s + e.actualUsd, 0);
171
+ // a segment's running total must be what the chain sums to through it (F-193)
172
+ if (
173
+ seg.total !== undefined &&
174
+ (seg.total.settledCount !== settledCount ||
175
+ !(Math.abs(seg.total.settledUsd - settledUsd) <= 1e-9))
176
+ )
177
+ throw new ArchiveError(
178
+ 'LEDGER_ARCHIVE_BROKEN',
179
+ `cold segment ${seq}'s running total does not match the chain`,
180
+ { seq },
181
+ );
182
+ prev = seg.hash;
183
+ }
184
+ if (prev !== archive.tip)
185
+ throw new ArchiveError('LEDGER_ARCHIVE_BROKEN', 'the chain tip does not match the summary', {});
186
+ if (settledCount !== archive.settledCount)
187
+ throw new ArchiveError('LEDGER_ARCHIVE_BROKEN', 'the sealed count does not match the summary', {
188
+ counted: settledCount,
189
+ summary: archive.settledCount,
190
+ });
191
+ // written so a NaN (an entry with no amount) fails rather than passes
192
+ if (!(Math.abs(settledUsd - archive.settledUsd) <= 1e-9))
193
+ throw new ArchiveError('LEDGER_ARCHIVE_BROKEN', 'the sealed total does not match the summary', {
194
+ counted: settledUsd,
195
+ summary: archive.settledUsd,
196
+ });
197
+ // the index is the ONLY cross-history duplicate guard; a lost or wrong shard
198
+ // would silently re-admit a sealed call-id (double-spend), so verify checks
199
+ // every sealed call-id is in its shard pointing at its segment (F-105 review HIGH)
200
+ for (const [s, want] of expectedIndex) {
201
+ const p = join(idxDir(ledgerPath), `${s}.json`);
202
+ let have = {};
203
+ if (existsSync(p)) {
204
+ try {
205
+ have = JSON.parse(readFileSync(p, 'utf8'));
206
+ } catch (e) {
207
+ throw new ArchiveError('LEDGER_ARCHIVE_BROKEN', `index shard ${s} is unreadable`, {
208
+ why: e.code ?? String(e.message).slice(0, 80),
209
+ });
210
+ }
211
+ }
212
+ for (const [callId, seq] of Object.entries(want))
213
+ if (have[callId] !== seq)
214
+ throw new ArchiveError(
215
+ 'LEDGER_ARCHIVE_BROKEN',
216
+ `the index is missing or wrong for a sealed call-id in shard ${s} — run sakk ledger reindex`,
217
+ { shard: s },
218
+ );
219
+ }
220
+ return { segments: archive.segments, settledCount, settledUsd };
221
+ }
222
+
223
+ /**
224
+ * The per-call binding of the summary to the cold trail (F-193), run by
225
+ * reserve and by the seal under the ledger lock, after adoptLeftover. Reads
226
+ * one segment, the head, whatever the history, and checks:
227
+ * - the head exists, passes its hash, and is the summary's tip;
228
+ * - the summary's totals equal the head's running total (hash-covered);
229
+ * - no segment N+1 or N+2 past the summary exists. A crashed seal's leftover
230
+ * is adopted before this runs, so one still there means the summary was
231
+ * rolled back or dropped. A stray file further out costs a folder listing
232
+ * per call to find, so it is left to `sakk ledger verify`.
233
+ * A head sealed before F-193 has no running total: the chain is walked once
234
+ * per call until the next seal writes one. Throws LEDGER_ARCHIVE_BROKEN.
235
+ *
236
+ * Limit: the hash is unkeyed and the trail is a local file, so a writer who
237
+ * rewrites the summary, deletes the newer segments and their index entries,
238
+ * or rehashes the trail is not caught here. Keeping the agent from writing the
239
+ * ledger at all is the guard's job (it denies writes into ~/.sakk).
240
+ */
241
+ export function checkHead(ledgerPath, archive) {
242
+ validateArchive(archive, ledgerPath);
243
+ const bad = (why) => {
244
+ throw new ArchiveError('LEDGER_ARCHIVE_BROKEN', `${why} — run sakk ledger verify`, {
245
+ ledgerPath,
246
+ });
247
+ };
248
+ for (const past of [archive.segments + 1, archive.segments + 2])
249
+ if (existsSync(segPath(ledgerPath, past)))
250
+ bad(`cold segment ${past} exists past the summary — the summary was rolled back`);
251
+ if (archive.segments === 0) {
252
+ if (archive.settledCount !== 0 || archive.settledUsd !== 0)
253
+ bad('the summary has sealed totals but no segments');
254
+ return;
255
+ }
256
+ const head = readSegment(ledgerPath, archive.segments);
257
+ if (head.hash !== archive.tip) bad('the summary tip is not the head segment');
258
+ let total = head.total;
259
+ if (total === undefined) {
260
+ // legacy head: sum the whole chain (the same per-segment order as the seal)
261
+ total = { settledCount: 0, settledUsd: 0 };
262
+ let prev = '';
263
+ for (let seq = 1; seq <= archive.segments; seq++) {
264
+ const seg = seq === archive.segments ? head : readSegment(ledgerPath, seq);
265
+ // written prev-first so the MUT-AG drill anchor (verifyChain's link check) stays unique
266
+ if (prev !== seg.prev) bad(`cold segment ${seq} does not link to ${seq - 1}`);
267
+ // a string amount would concatenate, and "0" + "0.5" - 0.5 is 0: type-check first
268
+ if (
269
+ seg.entries.some((e) => typeof e?.actualUsd !== 'number' || !Number.isFinite(e.actualUsd))
270
+ )
271
+ bad(`cold segment ${seq} holds an entry with no valid amount`);
272
+ total.settledCount += seg.entries.length;
273
+ total.settledUsd += seg.entries.reduce((s, e) => s + e.actualUsd, 0);
274
+ prev = seg.hash;
275
+ }
276
+ }
277
+ // written so a NaN (a legacy entry with no amount) fails rather than passes
278
+ if (
279
+ total.settledCount !== archive.settledCount ||
280
+ !(Math.abs(total.settledUsd - archive.settledUsd) <= 1e-9)
281
+ )
282
+ bad('the summary totals do not match the head segment');
283
+ }
284
+
285
+ /**
286
+ * The segment a seal that crashed before its hot commit left past the summary
287
+ * (F-193 review). It is adopted as written, never sealed again: a segment
288
+ * number on disk is never rewritten, so no later edit of the hot file can
289
+ * change what segment N+1 says. Returns null when there is none, else
290
+ * { archive, entries }: the summary through it and its entries.
291
+ *
292
+ * Throws LEDGER_ARCHIVE_BROKEN unless it is a clean continuation of the
293
+ * summary: it links to the tip, no segment past it is on disk, every entry
294
+ * is a settled call with a unique id and a valid amount, its running total
295
+ * is the summary plus its entries, and every entry still in hot holds the
296
+ * same reservation, and the same amount if it settled there.
297
+ */
298
+ export function leftoverSegment(ledgerPath, archive, hotEntries) {
299
+ validateArchive(archive, ledgerPath);
300
+ const next = archive.segments + 1;
301
+ if (!existsSync(segPath(ledgerPath, next))) return null;
302
+ const bad = (why) => {
303
+ throw new ArchiveError(
304
+ 'LEDGER_ARCHIVE_BROKEN',
305
+ `cold segment ${next} exists past the summary — ${why} — run sakk ledger verify`,
306
+ { ledgerPath, seq: next },
307
+ );
308
+ };
309
+ const seg = readSegment(ledgerPath, next);
310
+ if (seg.prev !== archive.tip) bad('it does not link to the summary tip');
311
+ // rare path (only after a crash): one folder listing
312
+ if (seqsOnDisk(ledgerPath).some((s) => s > next)) bad('the summary was rolled back');
313
+ const hot = new Map(hotEntries.map((e) => [e?.callId, e]));
314
+ const ids = new Set();
315
+ let usd = 0;
316
+ for (const e of seg.entries) {
317
+ if (
318
+ e === null ||
319
+ typeof e !== 'object' ||
320
+ typeof e.callId !== 'string' ||
321
+ e.callId === '' ||
322
+ ids.has(e.callId) ||
323
+ e.status !== 'SETTLED' ||
324
+ typeof e.actualUsd !== 'number' ||
325
+ !Number.isFinite(e.actualUsd) ||
326
+ e.actualUsd < 0
327
+ )
328
+ bad('it holds an entry that is not a settled call');
329
+ ids.add(e.callId);
330
+ const h = hot.get(e.callId);
331
+ // the crash left hot as it was before that settle, so it holds every
332
+ // entry the seal took: one missing means hot was rolled back
333
+ if (
334
+ h === undefined ||
335
+ h.estimatedMaxUsd !== e.estimatedMaxUsd ||
336
+ (h.status === 'SETTLED' && h.actualUsd !== e.actualUsd)
337
+ )
338
+ bad(`its entry ${e.callId} does not match the hot file`);
339
+ usd += e.actualUsd;
340
+ }
341
+ const total = {
342
+ settledCount: archive.settledCount + seg.entries.length,
343
+ settledUsd: archive.settledUsd + usd,
344
+ };
345
+ // written so a NaN fails rather than passes
346
+ if (
347
+ seg.total !== undefined &&
348
+ (seg.total.settledCount !== total.settledCount ||
349
+ !(Math.abs(seg.total.settledUsd - total.settledUsd) <= 1e-9))
350
+ )
351
+ bad('its running total is not the summary plus its entries');
352
+ return {
353
+ archive: { segments: next, ...(seg.total ?? total), tip: seg.hash },
354
+ entries: seg.entries,
355
+ };
356
+ }
357
+
358
+ /**
359
+ * The cold segment files on disk are exactly 1..segments. A gap, a missing
360
+ * segment or one past the summary is LEDGER_ARCHIVE_BROKEN (verify adopts a
361
+ * crashed seal's leftover into the summary it checks first). Lists the cold
362
+ * folder once; used by `sakk ledger verify`, off the gate path.
363
+ */
364
+ export function checkSegmentFiles(ledgerPath, archive) {
365
+ validateArchive(archive, ledgerPath);
366
+ const seqs = seqsOnDisk(ledgerPath);
367
+ if (seqs.length !== archive.segments || !seqs.every((s, i) => s === i + 1))
368
+ throw new ArchiveError(
369
+ 'LEDGER_ARCHIVE_BROKEN',
370
+ `the cold segment files on disk are not 1..${archive.segments} (found ${seqs.length}, highest ${seqs.at(-1) ?? 0})`,
371
+ { ledgerPath },
372
+ );
373
+ return seqs.length;
374
+ }
375
+
376
+ /** Rebuild the whole index from the cold trail (sakk ledger reindex): recovery after an index loss. */
377
+ export function reindex(ledgerPath, archive) {
378
+ validateArchive(archive, ledgerPath);
379
+ const shards = new Map();
380
+ for (let seq = 1; seq <= archive.segments; seq++)
381
+ for (const e of readSegment(ledgerPath, seq).entries) {
382
+ const s = shardOf(e.callId);
383
+ if (!shards.has(s)) shards.set(s, {});
384
+ shards.get(s)[e.callId] = seq;
385
+ }
386
+ mkdirSync(idxDir(ledgerPath), { recursive: true });
387
+ for (const [s, map] of shards)
388
+ writeFileAtomic(join(idxDir(ledgerPath), `${s}.json`), `${JSON.stringify(map)}\n`);
389
+ return shards.size;
390
+ }
391
+
392
+ /** The segment a call-id was sealed into, or null if it was never sealed. One small shard read. */
393
+ export function indexLookup(ledgerPath, callId) {
394
+ const p = shardPath(ledgerPath, callId);
395
+ if (!existsSync(p)) return null;
396
+ let shard;
397
+ try {
398
+ shard = JSON.parse(readFileSync(p, 'utf8'));
399
+ } catch (e) {
400
+ throw new ArchiveError('LEDGER_ARCHIVE_BROKEN', `the index shard for ${callId} is unreadable`, {
401
+ why: e.code ?? String(e.message).slice(0, 80),
402
+ });
403
+ }
404
+ return Object.hasOwn(shard, callId) ? shard[callId] : null;
405
+ }
406
+
407
+ /** Every settled entry of the segment a call-id is in — for a re-settle idempotency check. */
408
+ export function archivedEntry(ledgerPath, callId) {
409
+ const seq = indexLookup(ledgerPath, callId);
410
+ if (seq === null) return null;
411
+ const seg = readSegment(ledgerPath, seq);
412
+ return seg.entries.find((e) => e.callId === callId) ?? null;
413
+ }
414
+
415
+ /**
416
+ * Seal `entries` (all SETTLED) as the next segment and record their call-ids
417
+ * in the index, in the crash-safe order the spec sets: the segment first,
418
+ * then the shards, then the caller commits the hot file. A crash before that
419
+ * commit leaves the segment past the summary, and the next reserve or settle
420
+ * adopts it as written (leftoverSegment). `total` is the running totals
421
+ * through this segment, which binds the summary (F-193); omitted, the segment
422
+ * is the pre-F-193 shape. Returns { seq, hash }.
423
+ */
424
+ export function sealSegment(ledgerPath, prevSegments, prevTip, entries, total) {
425
+ mkdirSync(coldDir(ledgerPath), { recursive: true });
426
+ const seq = prevSegments + 1;
427
+ // a segment number on disk is never written again: rewriting seq, or seq
428
+ // under a trail that goes past it, would replace history (F-193 review);
429
+ // checkHead refuses that first, this holds it for any other caller
430
+ for (const taken of [seq, seq + 1])
431
+ if (existsSync(segPath(ledgerPath, taken)))
432
+ throw new ArchiveError(
433
+ 'LEDGER_ARCHIVE_BROKEN',
434
+ `cold segment ${taken} already exists — refusing to rewrite segment ${seq}`,
435
+ { ledgerPath },
436
+ );
437
+ const hash = segmentHash(seq, prevTip, entries, total);
438
+ writeFileAtomic(
439
+ segPath(ledgerPath, seq),
440
+ `${JSON.stringify(total === undefined ? { seq, prev: prevTip, entries, hash } : { seq, prev: prevTip, entries, total, hash })}\n`,
441
+ );
442
+ indexSegment(ledgerPath, seq, entries);
443
+ return { seq, hash };
444
+ }
445
+
446
+ /**
447
+ * Record a segment's call-ids in the index: each shard they fall in is merged
448
+ * and rewritten. Idempotent, so adopting a crashed seal's leftover can redo
449
+ * it whether or not the crash came before the shards were written.
450
+ */
451
+ export function indexSegment(ledgerPath, seq, entries) {
452
+ mkdirSync(idxDir(ledgerPath), { recursive: true });
453
+ const byShard = new Map();
454
+ for (const e of entries) {
455
+ const s = shardOf(e.callId);
456
+ if (!byShard.has(s)) byShard.set(s, []);
457
+ byShard.get(s).push(e.callId);
458
+ }
459
+ for (const [s, ids] of byShard) {
460
+ const p = join(idxDir(ledgerPath), `${s}.json`);
461
+ let shard = {};
462
+ if (existsSync(p)) {
463
+ try {
464
+ shard = JSON.parse(readFileSync(p, 'utf8'));
465
+ } catch (e) {
466
+ throw new ArchiveError('LEDGER_ARCHIVE_BROKEN', `the index shard ${s} is unreadable`, {
467
+ why: e.code ?? String(e.message).slice(0, 80),
468
+ });
469
+ }
470
+ }
471
+ for (const id of ids) shard[id] = seq;
472
+ writeFileAtomic(p, `${JSON.stringify(shard)}\n`);
473
+ }
474
+ }
475
+
476
+ /** How many cold segment files are actually on disk (for verify / export). */
477
+ export function segmentsOnDisk(ledgerPath) {
478
+ return seqsOnDisk(ledgerPath).length;
479
+ }
480
+
481
+ /** The seq numbers of the segment files on disk, ascending. Lists the cold folder once. */
482
+ function seqsOnDisk(ledgerPath) {
483
+ const d = coldDir(ledgerPath);
484
+ if (!existsSync(d)) return [];
485
+ return readdirSync(d)
486
+ .filter((f) => /^\d{6,}\.json$/.test(f))
487
+ .map((f) => Number(f.slice(0, -'.json'.length)))
488
+ .sort((a, b) => a - b);
489
+ }