patchwork-os 1.2.0-beta.2.canary.783 → 1.2.0-beta.2.canary.786

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Tamper-evident JSONL ledgers — ADR-0027.
3
+ *
4
+ * ONE append primitive for every ledger on the evidence spine, and ONE
5
+ * verifier over the files it writes. The chain is a property of the BYTES on
6
+ * disk: `iseq` is a per-ledger integrity sequence read from the file's tail
7
+ * under the cross-process lock, and `prev` is the SHA-256 of the exact bytes
8
+ * of the previous line. Nothing a process remembers is trusted for either —
9
+ * two bridges sharing one `$PATCHWORK_HOME` is the normal case, not the edge.
10
+ *
11
+ * ## What is and is not repurposed
12
+ *
13
+ * `seq` on the receipt and gate ledgers is a per-PROCESS counter and stays
14
+ * exactly what it was. `rv` is a schema level and stays one. The integrity
15
+ * fields are new names ADDED behind a per-ledger `rv` bump, so a row written
16
+ * before the chain existed is distinguishable from one that should carry it —
17
+ * the sentinel ADR-0025 required before any stamping.
18
+ *
19
+ * ## Marker rows carry `kind` and NEVER `seq`
20
+ *
21
+ * Two marker shapes live in the same file as the data. Every existing loader
22
+ * keys on its own data fields (`seq` + `workerId` + `toolName` + `action` for
23
+ * the gate, numeric `seq` for receipts, known `kind`s for approvals), so a
24
+ * marker with no `seq` is skipped by all of them. Adding `seq` to a marker
25
+ * would put it in the receipt ring as a receipt. Do not.
26
+ *
27
+ * ## Legacy rows are byte-identical and never re-stamped
28
+ *
29
+ * The first chained append to a file that has no chain writes a `chain-start`
30
+ * marker committing to the SHA-256 of every byte before it. That makes the old
31
+ * evidence verifiable from the migration boundary without touching it, which
32
+ * is the never-backfill doctrine applied rather than bent.
33
+ *
34
+ * ## A failed append is a different fact from "nobody recorded this"
35
+ *
36
+ * Every observability writer swallows its own errors on purpose. The cost was
37
+ * that a ledger that silently stopped writing looked like a quiet day. Now a
38
+ * failure is counted in `<file>.write_failed` (when and which errno, never a
39
+ * payload), the NEXT successful row seals the count as `writeFailed: N`, and
40
+ * until it does the verifier reports the count as PENDING.
41
+ *
42
+ * ## What this cannot do
43
+ *
44
+ * Prove authorship. Anyone with the operator's uid can rewrite the file and
45
+ * both sidecars together. What they cannot do is change one row and leave the
46
+ * rest standing, or shorten the file without the head sidecar disagreeing.
47
+ * Signing is a control-plane concern (ADR-0019); no key material lives here.
48
+ */
49
+ export declare const CHAIN_HASH_ALGORITHM = "sha256";
50
+ /** Written once: commits to the bytes that precede it. */
51
+ export interface ChainStartMarker {
52
+ kind: "chain-start";
53
+ at: number;
54
+ legacyRows: number;
55
+ legacyBytes: number;
56
+ legacyHash: string;
57
+ }
58
+ /**
59
+ * Written as the new FIRST line whenever rotation removes rows. Counts are
60
+ * CUMULATIVE across rotations, so one marker answers "how many rows has this
61
+ * file ever lost to rotation". `lastDroppedHash` re-anchors the chain: the
62
+ * first kept chained row's `prev` must equal it.
63
+ */
64
+ export interface RotationMarker {
65
+ kind: "rotation";
66
+ at: number;
67
+ droppedRows: number;
68
+ droppedLegacyRows: number;
69
+ lastDroppedHash: string | null;
70
+ lastDroppedIseq: number | null;
71
+ firstKeptIseq: number | null;
72
+ }
73
+ export interface AppendChainedOptions {
74
+ now?: () => number;
75
+ /** Rotate when the file exceeds this many bytes. Absent ⇒ never rotate. */
76
+ maxBytes?: number;
77
+ /** Low-water target after rotation. Defaults to 90% of `maxBytes`. */
78
+ rotateTarget?: number;
79
+ /** Rotate when the file exceeds this many lines. Absent ⇒ no line cap. */
80
+ maxLines?: number;
81
+ mode?: number;
82
+ lockTimeoutMs?: number;
83
+ /**
84
+ * Called after a rotation actually removed rows, with how many THIS rotation
85
+ * dropped and how many the file held before. The gate ledger warns with the
86
+ * count because coverage measures over a rotated file converge toward 1.0
87
+ * by deletion (ADR-0025), and a number nobody prints is a number nobody
88
+ * reads.
89
+ */
90
+ onRotate?: (info: {
91
+ dropped: number;
92
+ before: number;
93
+ }) => void;
94
+ }
95
+ export type ChainBreakKind = "hash" | "gap" | "duplicate" | "order" | "truncated" | "unchained" | "malformed-row" | "malformed-marker";
96
+ export interface ChainBreak {
97
+ kind: ChainBreakKind;
98
+ /** 1-based line in the file. A position, never a value. */
99
+ line: number;
100
+ iseq: number | null;
101
+ }
102
+ export interface ChainVerification {
103
+ file: string;
104
+ absent: boolean;
105
+ /**
106
+ * The chain is internally intact: no breaks, no legacy mismatch, and the
107
+ * head sidecar does not say the file used to be longer. Pending write
108
+ * failures do NOT flip this — the chain is intact; the spine-level report
109
+ * is where "a ledger stopped writing" becomes not-ok.
110
+ */
111
+ ok: boolean;
112
+ /** Rows before the chain-start marker in THIS file. */
113
+ legacyRows: number;
114
+ legacyPrefix: "none" | "unchained" | "verified" | "mismatch" | "unverifiable-rotated";
115
+ chainedRows: number;
116
+ lastIseq: number | null;
117
+ rotations: number;
118
+ /** Chained rows ever removed by rotation, from the marker's cumulative count. */
119
+ droppedByRotation: number;
120
+ breaks: ChainBreak[];
121
+ writeFailedSealed: number;
122
+ writeFailedPending: number;
123
+ head: "ok" | "missing" | "truncated" | "mismatch" | "stale" | "n/a";
124
+ }
125
+ /**
126
+ * Is this parsed row one of the two ADR-0027 marker shapes? Shared by every
127
+ * reader that must not count a marker as data — one predicate, so the set of
128
+ * marker kinds cannot drift between readers.
129
+ */
130
+ export declare function isChainMarker(row: unknown): boolean;
131
+ export declare function hashLine(line: string): string;
132
+ export declare function chainSidecarPaths(file: string): {
133
+ head: string;
134
+ writeFailed: string;
135
+ };
136
+ /**
137
+ * Count one failed append. Never throws and never carries a payload: the
138
+ * whole point is that the row that failed to land is not on disk anywhere.
139
+ */
140
+ export declare function recordWriteFailure(file: string, code: string, now?: () => number): void;
141
+ /**
142
+ * Append one row to a chained ledger. Under the cross-process lock: rotate if
143
+ * over cap, read the tail, stamp `iseq` / `prev` / `writeFailed`, append,
144
+ * update the head sidecar, clear the failure counter.
145
+ *
146
+ * The integrity fields are stamped AFTER the caller's fields, so a row cannot
147
+ * forge its own position. The caller's object is never mutated.
148
+ *
149
+ * Throws on failure — after counting it in the write_failed sidecar — so a
150
+ * writer keeps whatever contract it already has (most swallow and warn).
151
+ */
152
+ export declare function appendChained(file: string, row: Record<string, unknown>, opts?: AppendChainedOptions): {
153
+ line: string;
154
+ iseq: number;
155
+ };
156
+ /**
157
+ * Verify one ledger from its bytes. Read-only. Reports positions (line, iseq)
158
+ * and counts, never a value.
159
+ */
160
+ export declare function verifyLedgerChain(file: string): ChainVerification;
@@ -0,0 +1,515 @@
1
+ /**
2
+ * Tamper-evident JSONL ledgers — ADR-0027.
3
+ *
4
+ * ONE append primitive for every ledger on the evidence spine, and ONE
5
+ * verifier over the files it writes. The chain is a property of the BYTES on
6
+ * disk: `iseq` is a per-ledger integrity sequence read from the file's tail
7
+ * under the cross-process lock, and `prev` is the SHA-256 of the exact bytes
8
+ * of the previous line. Nothing a process remembers is trusted for either —
9
+ * two bridges sharing one `$PATCHWORK_HOME` is the normal case, not the edge.
10
+ *
11
+ * ## What is and is not repurposed
12
+ *
13
+ * `seq` on the receipt and gate ledgers is a per-PROCESS counter and stays
14
+ * exactly what it was. `rv` is a schema level and stays one. The integrity
15
+ * fields are new names ADDED behind a per-ledger `rv` bump, so a row written
16
+ * before the chain existed is distinguishable from one that should carry it —
17
+ * the sentinel ADR-0025 required before any stamping.
18
+ *
19
+ * ## Marker rows carry `kind` and NEVER `seq`
20
+ *
21
+ * Two marker shapes live in the same file as the data. Every existing loader
22
+ * keys on its own data fields (`seq` + `workerId` + `toolName` + `action` for
23
+ * the gate, numeric `seq` for receipts, known `kind`s for approvals), so a
24
+ * marker with no `seq` is skipped by all of them. Adding `seq` to a marker
25
+ * would put it in the receipt ring as a receipt. Do not.
26
+ *
27
+ * ## Legacy rows are byte-identical and never re-stamped
28
+ *
29
+ * The first chained append to a file that has no chain writes a `chain-start`
30
+ * marker committing to the SHA-256 of every byte before it. That makes the old
31
+ * evidence verifiable from the migration boundary without touching it, which
32
+ * is the never-backfill doctrine applied rather than bent.
33
+ *
34
+ * ## A failed append is a different fact from "nobody recorded this"
35
+ *
36
+ * Every observability writer swallows its own errors on purpose. The cost was
37
+ * that a ledger that silently stopped writing looked like a quiet day. Now a
38
+ * failure is counted in `<file>.write_failed` (when and which errno, never a
39
+ * payload), the NEXT successful row seals the count as `writeFailed: N`, and
40
+ * until it does the verifier reports the count as PENDING.
41
+ *
42
+ * ## What this cannot do
43
+ *
44
+ * Prove authorship. Anyone with the operator's uid can rewrite the file and
45
+ * both sidecars together. What they cannot do is change one row and leave the
46
+ * rest standing, or shorten the file without the head sidecar disagreeing.
47
+ * Signing is a control-plane concern (ADR-0019); no key material lives here.
48
+ */
49
+ import { createHash } from "node:crypto";
50
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync, } from "node:fs";
51
+ import path from "node:path";
52
+ import { withFileLockSync } from "./fileLockSync.js";
53
+ export const CHAIN_HASH_ALGORITHM = "sha256";
54
+ /**
55
+ * Is this parsed row one of the two ADR-0027 marker shapes? Shared by every
56
+ * reader that must not count a marker as data — one predicate, so the set of
57
+ * marker kinds cannot drift between readers.
58
+ */
59
+ export function isChainMarker(row) {
60
+ if (row === null || typeof row !== "object")
61
+ return false;
62
+ const k = row.kind;
63
+ return k === "chain-start" || k === "rotation";
64
+ }
65
+ export function hashLine(line) {
66
+ return createHash(CHAIN_HASH_ALGORITHM).update(line, "utf8").digest("hex");
67
+ }
68
+ export function chainSidecarPaths(file) {
69
+ return { head: `${file}.head`, writeFailed: `${file}.write_failed` };
70
+ }
71
+ /**
72
+ * Count one failed append. Never throws and never carries a payload: the
73
+ * whole point is that the row that failed to land is not on disk anywhere.
74
+ */
75
+ export function recordWriteFailure(file, code, now = Date.now) {
76
+ try {
77
+ mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
78
+ appendFileSync(chainSidecarPaths(file).writeFailed, `${JSON.stringify({ at: now(), code })}\n`, { mode: 0o600 });
79
+ }
80
+ catch {
81
+ /* the disk that lost the row is losing the counter too; nothing to do */
82
+ }
83
+ }
84
+ function countLines(file) {
85
+ try {
86
+ return readFileSync(file, "utf-8")
87
+ .split("\n")
88
+ .filter((l) => l.length > 0).length;
89
+ }
90
+ catch {
91
+ return 0;
92
+ }
93
+ }
94
+ function splitLines(text) {
95
+ const out = text.split("\n");
96
+ if (out.length > 0 && out[out.length - 1] === "")
97
+ out.pop();
98
+ return out;
99
+ }
100
+ function parseLine(line) {
101
+ try {
102
+ const v = JSON.parse(line);
103
+ return v !== null && typeof v === "object" && !Array.isArray(v)
104
+ ? v
105
+ : null;
106
+ }
107
+ catch {
108
+ return null;
109
+ }
110
+ }
111
+ function isFiniteNumber(v) {
112
+ return typeof v === "number" && Number.isFinite(v);
113
+ }
114
+ function atomicWrite(file, text, mode) {
115
+ const tmp = `${file}.tmp`;
116
+ writeFileSync(tmp, text, { mode });
117
+ // Windows: a rename onto an existing target can fail TRANSIENTLY with
118
+ // EPERM / EBUSY while an indexer or scanner holds the old file, and with
119
+ // EEXIST on some filesystems. Retry briefly, then fall back to unlink +
120
+ // rename for EEXIST (the gate ledger's original path). POSIX never enters
121
+ // the loop: rename there is atomic and replaces.
122
+ const attempts = process.platform === "win32" ? 20 : 1;
123
+ for (let n = 1;; n++) {
124
+ try {
125
+ renameSync(tmp, file);
126
+ return;
127
+ }
128
+ catch (err) {
129
+ const code = err.code;
130
+ const transient = process.platform === "win32" &&
131
+ (code === "EPERM" || code === "EBUSY" || code === "EEXIST");
132
+ if (!transient || n >= attempts) {
133
+ if (process.platform === "win32" && code === "EEXIST") {
134
+ try {
135
+ unlinkSync(file);
136
+ }
137
+ catch {
138
+ /* best-effort */
139
+ }
140
+ renameSync(tmp, file);
141
+ return;
142
+ }
143
+ throw err;
144
+ }
145
+ const spinUntil = process.hrtime.bigint() + 10000000n; // 10ms
146
+ while (process.hrtime.bigint() < spinUntil) {
147
+ /* yield */
148
+ }
149
+ }
150
+ }
151
+ }
152
+ function readTail(lines) {
153
+ if (lines.length === 0)
154
+ return { unchained: true, prevHash: "", nextIseq: 1 };
155
+ let chained = false;
156
+ let lastIseq = null;
157
+ for (let i = lines.length - 1; i >= 0; i--) {
158
+ const p = parseLine(lines[i]);
159
+ if (!p)
160
+ continue;
161
+ if (isFiniteNumber(p.iseq)) {
162
+ chained = true;
163
+ lastIseq = p.iseq;
164
+ break;
165
+ }
166
+ if (p.kind === "rotation") {
167
+ chained = true;
168
+ lastIseq = isFiniteNumber(p.lastDroppedIseq) ? p.lastDroppedIseq : 0;
169
+ break;
170
+ }
171
+ if (p.kind === "chain-start") {
172
+ chained = true;
173
+ lastIseq = 0;
174
+ break;
175
+ }
176
+ }
177
+ if (!chained)
178
+ return { unchained: true, prevHash: "", nextIseq: 1 };
179
+ return {
180
+ unchained: false,
181
+ prevHash: hashLine(lines[lines.length - 1]),
182
+ nextIseq: (lastIseq ?? 0) + 1,
183
+ };
184
+ }
185
+ function rotate(file, lines, opts, now, mode) {
186
+ const maxBytes = opts.maxBytes ?? Number.POSITIVE_INFINITY;
187
+ const target = opts.rotateTarget ?? Math.floor(maxBytes * 0.9);
188
+ const maxLines = opts.maxLines ?? Number.POSITIVE_INFINITY;
189
+ // Fold the previous rotation marker's cumulative counts, then drop it: a
190
+ // file has exactly one rotation marker, at line 1.
191
+ let prior = null;
192
+ let body = lines;
193
+ const first = lines.length > 0 ? parseLine(lines[0]) : null;
194
+ if (first && first.kind === "rotation") {
195
+ prior = first;
196
+ body = lines.slice(1);
197
+ }
198
+ let budget = target;
199
+ let keepFrom = body.length;
200
+ let kept = 0;
201
+ for (let i = body.length - 1; i >= 0; i--) {
202
+ const cost = Buffer.byteLength(body[i], "utf8") + 1;
203
+ if (cost > budget || kept + 1 > maxLines)
204
+ break;
205
+ budget -= cost;
206
+ keepFrom = i;
207
+ kept++;
208
+ }
209
+ if (keepFrom === 0 && prior === null)
210
+ return lines; // nothing to drop
211
+ let droppedRows = prior?.droppedRows ?? 0;
212
+ let droppedLegacyRows = prior?.droppedLegacyRows ?? 0;
213
+ let lastDroppedIseq = prior?.lastDroppedIseq ?? null;
214
+ let lastDroppedHash = prior?.lastDroppedHash ?? null;
215
+ let seenChainStart = false;
216
+ for (let i = 0; i < keepFrom; i++) {
217
+ const line = body[i];
218
+ const p = parseLine(line);
219
+ if (p && isFiniteNumber(p.iseq)) {
220
+ droppedRows++;
221
+ lastDroppedIseq = p.iseq;
222
+ }
223
+ else if (p && p.kind === "chain-start") {
224
+ seenChainStart = true;
225
+ }
226
+ else if (!p || p.kind === undefined) {
227
+ // Anything before chain-start that is not a marker is legacy; so is an
228
+ // unparseable line, which the legacy hash committed to as bytes.
229
+ if (!seenChainStart)
230
+ droppedLegacyRows++;
231
+ }
232
+ lastDroppedHash = hashLine(line);
233
+ }
234
+ if (keepFrom > 0 && lastDroppedHash === null)
235
+ lastDroppedHash = null;
236
+ const keptLines = body.slice(keepFrom);
237
+ const firstKept = keptLines.length > 0 ? parseLine(keptLines[0]) : null;
238
+ const marker = {
239
+ kind: "rotation",
240
+ at: now,
241
+ droppedRows,
242
+ droppedLegacyRows,
243
+ lastDroppedHash,
244
+ lastDroppedIseq,
245
+ firstKeptIseq: firstKept && isFiniteNumber(firstKept.iseq) ? firstKept.iseq : null,
246
+ };
247
+ const out = [JSON.stringify(marker), ...keptLines];
248
+ atomicWrite(file, out.length > 0 ? `${out.join("\n")}\n` : "", mode);
249
+ return out;
250
+ }
251
+ /**
252
+ * Append one row to a chained ledger. Under the cross-process lock: rotate if
253
+ * over cap, read the tail, stamp `iseq` / `prev` / `writeFailed`, append,
254
+ * update the head sidecar, clear the failure counter.
255
+ *
256
+ * The integrity fields are stamped AFTER the caller's fields, so a row cannot
257
+ * forge its own position. The caller's object is never mutated.
258
+ *
259
+ * Throws on failure — after counting it in the write_failed sidecar — so a
260
+ * writer keeps whatever contract it already has (most swallow and warn).
261
+ */
262
+ export function appendChained(file, row, opts = {}) {
263
+ const now = opts.now ?? Date.now;
264
+ const mode = opts.mode ?? 0o600;
265
+ const side = chainSidecarPaths(file);
266
+ try {
267
+ mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
268
+ return withFileLockSync(file, () => {
269
+ let text = "";
270
+ try {
271
+ text = readFileSync(file, "utf-8");
272
+ }
273
+ catch (err) {
274
+ if (err.code !== "ENOENT")
275
+ throw err;
276
+ }
277
+ let lines = splitLines(text);
278
+ const overBytes = opts.maxBytes !== undefined &&
279
+ Buffer.byteLength(text, "utf8") > opts.maxBytes;
280
+ const overLines = opts.maxLines !== undefined && lines.length > opts.maxLines;
281
+ if (overBytes || overLines) {
282
+ const before = lines.length;
283
+ lines = rotate(file, lines, opts, now(), mode);
284
+ text = lines.length > 0 ? `${lines.join("\n")}\n` : "";
285
+ // The marker is one line, so `before - lines.length + 1` is what
286
+ // rotation removed; a file with a prior marker also lost that one.
287
+ const dropped = Math.max(0, before - (lines.length - 1));
288
+ if (dropped > 0)
289
+ opts.onRotate?.({ dropped, before });
290
+ }
291
+ const tail = readTail(lines);
292
+ let prevHash = tail.prevHash;
293
+ let iseq = tail.nextIseq;
294
+ if (tail.unchained) {
295
+ const marker = {
296
+ kind: "chain-start",
297
+ at: now(),
298
+ legacyRows: lines.length,
299
+ legacyBytes: Buffer.byteLength(text, "utf8"),
300
+ legacyHash: hashLine(text),
301
+ };
302
+ const markerLine = JSON.stringify(marker);
303
+ appendFileSync(file, `${markerLine}\n`, { mode });
304
+ prevHash = hashLine(markerLine);
305
+ iseq = 1;
306
+ }
307
+ const pending = countLines(side.writeFailed);
308
+ const stamped = {
309
+ ...row,
310
+ iseq,
311
+ prev: prevHash,
312
+ ...(pending > 0 ? { writeFailed: pending } : {}),
313
+ };
314
+ const line = JSON.stringify(stamped);
315
+ appendFileSync(file, `${line}\n`, { mode });
316
+ atomicWrite(side.head, `${JSON.stringify({ iseq, hash: hashLine(line) })}\n`, mode);
317
+ if (pending > 0)
318
+ rmSync(side.writeFailed, { force: true });
319
+ return { line, iseq };
320
+ }, { timeoutMs: opts.lockTimeoutMs ?? 5000 });
321
+ }
322
+ catch (err) {
323
+ recordWriteFailure(file, err.code ?? "EUNKNOWN", now);
324
+ throw err;
325
+ }
326
+ }
327
+ function readHead(file) {
328
+ try {
329
+ const p = parseLine(readFileSync(chainSidecarPaths(file).head, "utf-8").trim());
330
+ if (p && isFiniteNumber(p.iseq) && typeof p.hash === "string") {
331
+ return { iseq: p.iseq, hash: p.hash };
332
+ }
333
+ return null;
334
+ }
335
+ catch {
336
+ return null;
337
+ }
338
+ }
339
+ /**
340
+ * Verify one ledger from its bytes. Read-only. Reports positions (line, iseq)
341
+ * and counts, never a value.
342
+ */
343
+ export function verifyLedgerChain(file) {
344
+ const v = {
345
+ file,
346
+ absent: !existsSync(file),
347
+ ok: true,
348
+ legacyRows: 0,
349
+ legacyPrefix: "none",
350
+ chainedRows: 0,
351
+ lastIseq: null,
352
+ rotations: 0,
353
+ droppedByRotation: 0,
354
+ breaks: [],
355
+ writeFailedSealed: 0,
356
+ writeFailedPending: countLines(chainSidecarPaths(file).writeFailed),
357
+ head: "n/a",
358
+ };
359
+ if (v.absent)
360
+ return v;
361
+ const text = readFileSync(file, "utf-8");
362
+ const lines = splitLines(text);
363
+ // Every iseq in the file, so "the expected one exists later" (reordered) can
364
+ // be told apart from "it exists nowhere" (a gap).
365
+ const present = new Set();
366
+ for (const l of lines) {
367
+ const p = parseLine(l);
368
+ if (p && isFiniteNumber(p.iseq))
369
+ present.add(p.iseq);
370
+ }
371
+ let inChain = false;
372
+ let sawChainStart = false;
373
+ let legacyUnverifiable = false;
374
+ let legacyBytes = 0;
375
+ let expectedPrev = "";
376
+ let expectedIseq = null;
377
+ const seen = new Set();
378
+ let lastLine = "";
379
+ let lastHash = "";
380
+ for (let i = 0; i < lines.length; i++) {
381
+ const line = lines[i];
382
+ const lineNo = i + 1;
383
+ const p = parseLine(line);
384
+ const kind = p?.kind;
385
+ if (kind === "rotation") {
386
+ if (i !== 0 || !p) {
387
+ v.breaks.push({ kind: "malformed-marker", line: lineNo, iseq: null });
388
+ continue;
389
+ }
390
+ const ok = isFiniteNumber(p.droppedRows) &&
391
+ isFiniteNumber(p.droppedLegacyRows) &&
392
+ (p.lastDroppedHash === null || typeof p.lastDroppedHash === "string") &&
393
+ (p.lastDroppedIseq === null || isFiniteNumber(p.lastDroppedIseq));
394
+ if (!ok) {
395
+ v.breaks.push({ kind: "malformed-marker", line: lineNo, iseq: null });
396
+ continue;
397
+ }
398
+ v.rotations++;
399
+ v.droppedByRotation = p.droppedRows;
400
+ inChain = true;
401
+ if (p.droppedLegacyRows > 0)
402
+ legacyUnverifiable = true;
403
+ if (typeof p.lastDroppedHash === "string")
404
+ expectedPrev = p.lastDroppedHash;
405
+ if (isFiniteNumber(p.lastDroppedIseq))
406
+ expectedIseq = p.lastDroppedIseq + 1;
407
+ continue;
408
+ }
409
+ if (kind === "chain-start") {
410
+ const ok = p !== null &&
411
+ !sawChainStart &&
412
+ isFiniteNumber(p.legacyRows) &&
413
+ isFiniteNumber(p.legacyBytes) &&
414
+ typeof p.legacyHash === "string";
415
+ sawChainStart = true;
416
+ inChain = true;
417
+ if (!ok) {
418
+ v.breaks.push({ kind: "malformed-marker", line: lineNo, iseq: null });
419
+ }
420
+ else if (legacyUnverifiable) {
421
+ v.legacyPrefix = "unverifiable-rotated";
422
+ }
423
+ else if (v.legacyRows === 0 && p.legacyRows === 0) {
424
+ v.legacyPrefix = "none";
425
+ }
426
+ else {
427
+ const prefix = text.slice(0, legacyBytes);
428
+ v.legacyPrefix =
429
+ hashLine(prefix) === p.legacyHash &&
430
+ Buffer.byteLength(prefix, "utf8") === p.legacyBytes &&
431
+ v.legacyRows === p.legacyRows
432
+ ? "verified"
433
+ : "mismatch";
434
+ }
435
+ expectedPrev = hashLine(line);
436
+ if (expectedIseq === null)
437
+ expectedIseq = 1;
438
+ continue;
439
+ }
440
+ if (!inChain) {
441
+ // Legacy prefix: committed to as bytes, so the content is not judged.
442
+ v.legacyRows++;
443
+ legacyBytes += Buffer.byteLength(line, "utf8") + 1;
444
+ continue;
445
+ }
446
+ if (!p) {
447
+ v.breaks.push({ kind: "malformed-row", line: lineNo, iseq: null });
448
+ continue;
449
+ }
450
+ if (!isFiniteNumber(p.iseq)) {
451
+ v.breaks.push({ kind: "unchained", line: lineNo, iseq: null });
452
+ continue;
453
+ }
454
+ const iseq = p.iseq;
455
+ v.chainedRows++;
456
+ if (p.prev !== expectedPrev) {
457
+ v.breaks.push({ kind: "hash", line: lineNo, iseq });
458
+ }
459
+ if (expectedIseq !== null && iseq !== expectedIseq) {
460
+ let kind;
461
+ if (iseq > expectedIseq)
462
+ kind = present.has(expectedIseq) ? "order" : "gap";
463
+ else
464
+ kind = seen.has(iseq) ? "duplicate" : "order";
465
+ v.breaks.push({ kind, line: lineNo, iseq });
466
+ }
467
+ if (isFiniteNumber(p.writeFailed))
468
+ v.writeFailedSealed += p.writeFailed;
469
+ seen.add(iseq);
470
+ v.lastIseq = iseq;
471
+ expectedIseq = iseq + 1;
472
+ expectedPrev = hashLine(line);
473
+ lastLine = line;
474
+ lastHash = expectedPrev;
475
+ }
476
+ if (!inChain) {
477
+ v.legacyPrefix = v.legacyRows > 0 ? "unchained" : "none";
478
+ }
479
+ else if (legacyUnverifiable && v.legacyPrefix === "none") {
480
+ v.legacyPrefix = "unverifiable-rotated";
481
+ }
482
+ // The head sidecar is the only thing that can say the file used to be longer.
483
+ if (inChain) {
484
+ const head = readHead(file);
485
+ if (!head) {
486
+ v.head = "missing";
487
+ }
488
+ else if (v.lastIseq === null || head.iseq > v.lastIseq) {
489
+ v.head = "truncated";
490
+ v.breaks.push({
491
+ kind: "truncated",
492
+ line: lines.length,
493
+ iseq: v.lastIseq,
494
+ });
495
+ }
496
+ else if (head.iseq < v.lastIseq) {
497
+ v.head = "stale";
498
+ }
499
+ else if (head.hash !== lastHash || lastLine === "") {
500
+ v.head = "mismatch";
501
+ v.breaks.push({ kind: "hash", line: lines.length, iseq: v.lastIseq });
502
+ }
503
+ else {
504
+ v.head = "ok";
505
+ }
506
+ }
507
+ v.ok =
508
+ v.breaks.length === 0 &&
509
+ v.legacyPrefix !== "mismatch" &&
510
+ v.head !== "missing" &&
511
+ v.head !== "truncated" &&
512
+ v.head !== "mismatch";
513
+ return v;
514
+ }
515
+ //# sourceMappingURL=ledgerChain.js.map