@dzhechkov/harness-core 0.3.145 → 0.3.147
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/.dz-manifest.json +82 -34
- package/README.md +3 -1
- package/dist/compounding.d.ts +27 -0
- package/dist/compounding.d.ts.map +1 -1
- package/dist/compounding.js +29 -0
- package/dist/compounding.js.map +1 -1
- package/dist/cost-ledger.d.ts +318 -0
- package/dist/cost-ledger.d.ts.map +1 -0
- package/dist/cost-ledger.js +871 -0
- package/dist/cost-ledger.js.map +1 -0
- package/dist/cost-scoring.d.ts +9 -0
- package/dist/cost-scoring.d.ts.map +1 -1
- package/dist/cost-scoring.js +18 -0
- package/dist/cost-scoring.js.map +1 -1
- package/dist/event-chain.d.ts +302 -0
- package/dist/event-chain.d.ts.map +1 -0
- package/dist/event-chain.js +663 -0
- package/dist/event-chain.js.map +1 -0
- package/dist/index.d.ts +9 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -2
- package/dist/index.js.map +1 -1
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +26 -0
- package/dist/operations.js.map +1 -1
- package/dist/recall-usage.d.ts +52 -4
- package/dist/recall-usage.d.ts.map +1 -1
- package/dist/recall-usage.js +106 -21
- package/dist/recall-usage.js.map +1 -1
- package/dist/usage.d.ts +32 -0
- package/dist/usage.d.ts.map +1 -1
- package/dist/usage.js +59 -10
- package/dist/usage.js.map +1 -1
- package/package.json +3 -3
- package/sbom.json +153 -33
- package/src/compounding.ts +61 -0
- package/src/cost-ledger.ts +1105 -0
- package/src/cost-scoring.ts +17 -0
- package/src/event-chain.ts +870 -0
- package/src/index.ts +86 -0
- package/src/operations.ts +25 -0
- package/src/recall-usage.ts +142 -24
- package/src/usage.ts +74 -12
|
@@ -0,0 +1,870 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hash-chained, sequence-numbered learning-event logs (feature `event-chain`, ADR-001/ADR-002).
|
|
3
|
+
*
|
|
4
|
+
* `.dz/recall-usage.jsonl` and `.dz/guard-audit.jsonl` are the ENTIRE evidence base for every
|
|
5
|
+
* `dz compounding` verdict and for `dz guard promote`. Before this module a record had no identity
|
|
6
|
+
* and no link to its neighbour, so a defect in the code that REWRITES those files was invisible to
|
|
7
|
+
* everything except a human noticing a wrong number — which is exactly how the 2 → 4 → 6
|
|
8
|
+
* double-count in `compactRecallUsageLog` was found (fixed 2026-07-28, see `recall-usage.ts`).
|
|
9
|
+
*
|
|
10
|
+
* WHAT THIS IS. A corruption check for OUR OWN bugs: a buggy compaction, a torn write, two writers
|
|
11
|
+
* racing. Each appended record carries `seq` (monotonic within its segment) and `prevHash` (FNV-1a
|
|
12
|
+
* over the previous record's line, exactly as it sits on disk), and {@link verifyEventChain}
|
|
13
|
+
* classifies what it finds.
|
|
14
|
+
*
|
|
15
|
+
* WHAT THIS IS NOT — read {@link EVENT_CHAIN_SCOPE} before describing it to anyone. FNV-1a is not
|
|
16
|
+
* cryptography and the threat model has no adversary: anyone who can edit the log can recompute the
|
|
17
|
+
* chain in one line of code. That is deliberate and sufficient, because the failures we actually
|
|
18
|
+
* ship are our own. ruview's ADR-010 names a `TamperedEntry` class; this module does not, because a
|
|
19
|
+
* defect class called "Tampered" IS the over-claim.
|
|
20
|
+
*
|
|
21
|
+
* FNV-1a collisions are CONSTRUCTIBLE — a 32-bit hash has none of the collision resistance a
|
|
22
|
+
* cryptographic one does, and a reviewer demonstrated a pair (Codex QE LOW-8). That is a documented
|
|
23
|
+
* property, not a surprise: the chain detects ACCIDENT classes (a compaction bug, a torn write, a
|
|
24
|
+
* race), not chosen-input attacks, which is exactly the corruption-not-tamper scope above. Swapping
|
|
25
|
+
* in sha256 would buy nothing this threat model needs and would invite the over-claim back.
|
|
26
|
+
*
|
|
27
|
+
* Everything here is PURE — no filesystem, no clock. Writers own their IO (the house pattern from
|
|
28
|
+
* `recall-usage.ts`), which is what keeps the never-block discipline of the apply leg intact.
|
|
29
|
+
*
|
|
30
|
+
* @packageDocumentation
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** The one sentence that states what the chain is and is not. Printed by every verify surface. */
|
|
34
|
+
export const EVENT_CHAIN_SCOPE =
|
|
35
|
+
'corruption detection for our own bugs (compaction, torn writes, races) — FNV-1a is not cryptography, ' +
|
|
36
|
+
'the threat model has no adversary, and anyone who can edit the log can recompute the chain';
|
|
37
|
+
|
|
38
|
+
/** `prevHash` of the first record of a chain segment. */
|
|
39
|
+
export const EVENT_CHAIN_GENESIS_HASH = '00000000';
|
|
40
|
+
|
|
41
|
+
/** How many bytes of a log's tail a writer needs to read to find the last line. */
|
|
42
|
+
export const EVENT_CHAIN_TAIL_BYTES = 65_536;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Bytes charged per line for its chain fields when a rewriter budgets its output. Measured against
|
|
46
|
+
* the widest realistic shape: `,"seq":<up to 15 digits>,"prevHash":"xxxxxxxx",` plus the reset
|
|
47
|
+
* marker — deliberately generous, because under-charging would blow a byte cap.
|
|
48
|
+
*/
|
|
49
|
+
export const EVENT_CHAIN_FIELD_OVERHEAD_BYTES = 64;
|
|
50
|
+
|
|
51
|
+
/** Marker record a rewriter puts first so its output can be checked against its input (ADR-002). */
|
|
52
|
+
export const EVENT_CHAIN_LEDGER_KIND = 'chain-ledger';
|
|
53
|
+
|
|
54
|
+
const HEX8 = /^[0-9a-f]{8}$/;
|
|
55
|
+
|
|
56
|
+
export type EventChainDefectKind =
|
|
57
|
+
/** A record's `prevHash` does not match the line before it — or a hole where a link should be. */
|
|
58
|
+
| 'BrokenLink'
|
|
59
|
+
/** The same `seq` appears twice in one segment — a rewrite duplicated a line, or two writers raced. */
|
|
60
|
+
| 'DuplicateSeq'
|
|
61
|
+
/** `seq` went backwards without a recorded segment restart. */
|
|
62
|
+
| 'NonMonotonicSeq'
|
|
63
|
+
/** A line that is not a readable record — the shape a partial write leaves. */
|
|
64
|
+
| 'TornTail'
|
|
65
|
+
/** A rewrite accounts for more events than it measured in its input (ADR-002 — the 2 → 4 → 6 class). */
|
|
66
|
+
| 'DoubleCounted'
|
|
67
|
+
/** A rewrite's own three numbers do not add up, or claim an impossible drop (ADR-002 AM-3). */
|
|
68
|
+
| 'LedgerImbalance'
|
|
69
|
+
/** A ledger line whose fields are missing, mistyped or non-finite — never silently ignored (AM-5). */
|
|
70
|
+
| 'MalformedLedger'
|
|
71
|
+
/** A rewrite's claim was never completed: the segment restarted or the file ended first (AM-4). */
|
|
72
|
+
| 'ClaimInterrupted';
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The defect vocabulary, as data. ruview's ADR-010 names one more class that this list deliberately
|
|
76
|
+
* omits — a class whose name would assert exactly the promise {@link EVENT_CHAIN_SCOPE} refuses —
|
|
77
|
+
* and a test asserts no kind here ever grows that vocabulary.
|
|
78
|
+
*/
|
|
79
|
+
export const EVENT_CHAIN_DEFECT_KINDS: readonly EventChainDefectKind[] = [
|
|
80
|
+
'BrokenLink',
|
|
81
|
+
'DuplicateSeq',
|
|
82
|
+
'NonMonotonicSeq',
|
|
83
|
+
'TornTail',
|
|
84
|
+
'DoubleCounted',
|
|
85
|
+
'LedgerImbalance',
|
|
86
|
+
'MalformedLedger',
|
|
87
|
+
'ClaimInterrupted',
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
export interface EventChainDefect {
|
|
91
|
+
readonly kind: EventChainDefectKind;
|
|
92
|
+
/** 1-based index into the lines handed to the verifier. */
|
|
93
|
+
readonly line: number;
|
|
94
|
+
readonly detail: string;
|
|
95
|
+
readonly seq?: number;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface EventChainVerification {
|
|
99
|
+
readonly ok: boolean;
|
|
100
|
+
/** Non-empty lines examined. */
|
|
101
|
+
readonly lines: number;
|
|
102
|
+
/** Lines carrying a well-formed `seq` + `prevHash`. */
|
|
103
|
+
readonly chained: number;
|
|
104
|
+
/**
|
|
105
|
+
* Leading run of records written before chaining existed. LEGAL, never a defect — the chain is
|
|
106
|
+
* allowed to start mid-file. Reported so the uncovered part is honest rather than invisible.
|
|
107
|
+
*/
|
|
108
|
+
readonly preChainPrefix: number;
|
|
109
|
+
/** Recorded discontinuities (`chainReset: true`) — a torn tail the writer refused to paper over. */
|
|
110
|
+
readonly resets: number;
|
|
111
|
+
/** Highest `seq` seen in the last segment, or null when nothing is chained. */
|
|
112
|
+
readonly lastSeq: number | null;
|
|
113
|
+
readonly defects: readonly EventChainDefect[];
|
|
114
|
+
/** {@link EVENT_CHAIN_SCOPE}, carried in the result so no surface can print a verdict without it. */
|
|
115
|
+
readonly scope: string;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface ChainFields {
|
|
119
|
+
readonly seq: number;
|
|
120
|
+
readonly prevHash: string;
|
|
121
|
+
/** Present only when the writer had to start a fresh segment because the tail was unreadable. */
|
|
122
|
+
readonly chainReset?: true;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface EventChainLedger {
|
|
126
|
+
readonly kind: typeof EVENT_CHAIN_LEDGER_KIND;
|
|
127
|
+
/** Event weight of the INPUT, measured before aggregation. */
|
|
128
|
+
readonly sourceEvents: number;
|
|
129
|
+
/** What a byte budget deliberately discarded — so trimming is not mistaken for loss. */
|
|
130
|
+
readonly droppedEvents: number;
|
|
131
|
+
/** Last `seq` this rewrite wrote. Records past it are outside the claim. */
|
|
132
|
+
readonly throughSeq: number;
|
|
133
|
+
readonly compactedAt: string;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* FNV-1a, 32 bits, over both UTF-16 bytes of every code unit in order.
|
|
138
|
+
*
|
|
139
|
+
* Both bytes, unconditionally: folding with `& 0xff` would make every Cyrillic pair that shares a
|
|
140
|
+
* low byte collide, and these logs carry Russian prompts. 32 bits gives roughly a 1-in-4.3e9
|
|
141
|
+
* accidental-collision chance per link, which is the right size for detecting a bug and the wrong
|
|
142
|
+
* size for detecting an enemy — see {@link EVENT_CHAIN_SCOPE}.
|
|
143
|
+
*/
|
|
144
|
+
export function fnv1a32(text: string): string {
|
|
145
|
+
let h = 0x811c9dc5;
|
|
146
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
147
|
+
const c = text.charCodeAt(i);
|
|
148
|
+
h = Math.imul(h ^ (c & 0xff), 0x01000193) >>> 0;
|
|
149
|
+
h = Math.imul(h ^ ((c >>> 8) & 0xff), 0x01000193) >>> 0;
|
|
150
|
+
}
|
|
151
|
+
return h.toString(16).padStart(8, '0');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Strip the line terminator a reader may have left, so writer and verifier hash the same string. */
|
|
155
|
+
function stripEol(line: string): string {
|
|
156
|
+
return line.endsWith('\r') ? line.slice(0, -1) : line;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The hash a record's successor must carry. Input is the line AS WRITTEN (ADR-001 decision 2). */
|
|
160
|
+
export function chainHashOf(line: string): string {
|
|
161
|
+
return fnv1a32(stripEol(line));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Non-empty lines of a JSONL text, terminators stripped. The one splitter both sides use. */
|
|
165
|
+
export function chainLinesOf(text: string): string[] {
|
|
166
|
+
const out: string[] = [];
|
|
167
|
+
for (const raw of text.split('\n')) {
|
|
168
|
+
const line = stripEol(raw);
|
|
169
|
+
if (line.trim() !== '') out.push(line);
|
|
170
|
+
}
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Last usable line of a log's TAIL chunk — all a writer needs to extend the chain (FR-2).
|
|
176
|
+
*
|
|
177
|
+
* When the chunk does not start at byte 0 its first line may be a fragment; with more than one line
|
|
178
|
+
* present the fragment is simply not the last one, and with exactly one line the caller is looking
|
|
179
|
+
* at a single record longer than {@link EVENT_CHAIN_TAIL_BYTES}, which no writer here produces —
|
|
180
|
+
* that returns `undefined`, and the caller starts a marked segment rather than chaining onto a guess.
|
|
181
|
+
*/
|
|
182
|
+
export function lastChainLine(tailText: string, opts: { readonly partial?: boolean } = {}): string | undefined {
|
|
183
|
+
if (typeof tailText !== 'string') return undefined;
|
|
184
|
+
const lines = chainLinesOf(tailText);
|
|
185
|
+
if (lines.length === 0) return undefined;
|
|
186
|
+
if (opts.partial === true && !tailText.includes('\n')) return undefined;
|
|
187
|
+
return lines[lines.length - 1];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** What an appender needs to know about the file it is about to extend. */
|
|
191
|
+
export interface LogTail {
|
|
192
|
+
readonly lastLine: string | undefined;
|
|
193
|
+
/**
|
|
194
|
+
* False when the file's final byte is not a newline — the shape a torn write leaves.
|
|
195
|
+
*
|
|
196
|
+
* LOAD-BEARING: appending straight onto a fragment GLUES the new record to it, so one torn write
|
|
197
|
+
* silently eats the next one too. (Found by the CLI torn-tail test, 2026-07-29 — the first append
|
|
198
|
+
* after a truncated line produced a single unreadable line instead of a marked restart.) The
|
|
199
|
+
* appender emits a leading newline so the damage stays exactly one record wide.
|
|
200
|
+
*/
|
|
201
|
+
readonly endsWithNewline: boolean;
|
|
202
|
+
/**
|
|
203
|
+
* True when the file is NOT empty but no complete record could be found in the tail window.
|
|
204
|
+
*
|
|
205
|
+
* AM-6 (Codex QE MED-6): without this, a torn record longer than {@link EVENT_CHAIN_TAIL_BYTES}
|
|
206
|
+
* gave `lastLine: undefined`, which is indistinguishable from an EMPTY file — so the appender
|
|
207
|
+
* started an UNMARKED genesis segment and quietly broke the marked-segment contract that ADR-001
|
|
208
|
+
* D4 exists to enforce. "I saw nothing" and "there is nothing" are different facts.
|
|
209
|
+
*/
|
|
210
|
+
readonly unreadable: boolean;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Read a tail chunk into the facts an appender needs. `partial` ⇒ the chunk starts mid-file. */
|
|
214
|
+
export function readTailInfo(tailText: string, opts: { readonly partial?: boolean } = {}): LogTail {
|
|
215
|
+
const text = typeof tailText === 'string' ? tailText : '';
|
|
216
|
+
const lastLine = lastChainLine(text, opts);
|
|
217
|
+
return {
|
|
218
|
+
lastLine,
|
|
219
|
+
endsWithNewline: text === '' || text.endsWith('\n'),
|
|
220
|
+
unreadable: lastLine === undefined && text.trim() !== '',
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** An empty log — what an appender assumes when the file is absent. */
|
|
225
|
+
export const EMPTY_LOG_TAIL: LogTail = { lastLine: undefined, endsWithNewline: true, unreadable: false };
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The exact text to append for a run of records: chained, newline-terminated, and preceded by a
|
|
229
|
+
* newline when the file ends mid-line. THE one place that knows how to extend one of these logs —
|
|
230
|
+
* the recall hook and the guard audit must not each carry their own copy of this reasoning.
|
|
231
|
+
*/
|
|
232
|
+
export function appendChainedLines(records: readonly object[], tail: LogTail = EMPTY_LOG_TAIL): string {
|
|
233
|
+
// AM-6: an unreadable tail is a TORN tail, never an empty one — it must produce a MARKED segment.
|
|
234
|
+
const lines = chainRecordLines(records, tail.lastLine, { forceReset: tail.unreadable === true });
|
|
235
|
+
if (lines.length === 0) return '';
|
|
236
|
+
return `${tail.endsWithNewline === false ? '\n' : ''}${lines.join('\n')}\n`;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
interface ParsedChainRecord {
|
|
240
|
+
readonly record: Record<string, unknown>;
|
|
241
|
+
readonly seq: number | undefined;
|
|
242
|
+
readonly prevHash: string | undefined;
|
|
243
|
+
readonly reset: boolean;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function parseLine(line: string): ParsedChainRecord | undefined {
|
|
247
|
+
let value: unknown;
|
|
248
|
+
try {
|
|
249
|
+
value = JSON.parse(line) as unknown;
|
|
250
|
+
} catch {
|
|
251
|
+
return undefined;
|
|
252
|
+
}
|
|
253
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined;
|
|
254
|
+
const record = value as Record<string, unknown>;
|
|
255
|
+
const rawSeq = record['seq'];
|
|
256
|
+
const rawPrev = record['prevHash'];
|
|
257
|
+
const seq =
|
|
258
|
+
typeof rawSeq === 'number' && Number.isSafeInteger(rawSeq) && rawSeq >= 1 ? rawSeq : undefined;
|
|
259
|
+
const prevHash = typeof rawPrev === 'string' && HEX8.test(rawPrev) ? rawPrev : undefined;
|
|
260
|
+
return { record, seq, prevHash, reset: record['chainReset'] === true };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* The chain fields the next appended record must carry, derived from the last line ALONE.
|
|
265
|
+
*
|
|
266
|
+
* TOTAL by construction — it cannot throw for any input, because the apply-leg hook's top safety
|
|
267
|
+
* property is never-block (ADR-001 D2). Every unreadable shape resolves the same way: start a fresh
|
|
268
|
+
* segment AND record the discontinuity, so a break is visible instead of silently healed (D4).
|
|
269
|
+
*
|
|
270
|
+
* - no last line (new/empty file) → genesis, no marker: this is a chain START
|
|
271
|
+
* - last line is an unchained record → genesis, no marker: the chain starts mid-file (FR-5)
|
|
272
|
+
* - last line is a chained record → `seq + 1`, linked to that line's hash
|
|
273
|
+
* - last line is unreadable / has a bad `seq` → genesis + `chainReset: true` (FR-3)
|
|
274
|
+
*/
|
|
275
|
+
export function nextChainFields(
|
|
276
|
+
lastLine: string | undefined,
|
|
277
|
+
opts: { readonly forceReset?: boolean } = {},
|
|
278
|
+
): ChainFields {
|
|
279
|
+
try {
|
|
280
|
+
// AM-6: the caller saw a non-empty file it could not read a record from. That is a torn tail,
|
|
281
|
+
// and a torn tail is ALWAYS a marked segment — never a silent genesis.
|
|
282
|
+
if (opts.forceReset === true) {
|
|
283
|
+
return { seq: 1, prevHash: EVENT_CHAIN_GENESIS_HASH, chainReset: true };
|
|
284
|
+
}
|
|
285
|
+
if (typeof lastLine !== 'string' || lastLine.trim() === '') {
|
|
286
|
+
return { seq: 1, prevHash: EVENT_CHAIN_GENESIS_HASH };
|
|
287
|
+
}
|
|
288
|
+
const parsed = parseLine(stripEol(lastLine));
|
|
289
|
+
if (parsed === undefined) {
|
|
290
|
+
return { seq: 1, prevHash: EVENT_CHAIN_GENESIS_HASH, chainReset: true };
|
|
291
|
+
}
|
|
292
|
+
if (parsed.seq === undefined && parsed.prevHash === undefined) {
|
|
293
|
+
// A pre-chain record: this append is the first link, not a break.
|
|
294
|
+
return { seq: 1, prevHash: EVENT_CHAIN_GENESIS_HASH };
|
|
295
|
+
}
|
|
296
|
+
if (parsed.seq === undefined || parsed.prevHash === undefined || parsed.seq >= Number.MAX_SAFE_INTEGER) {
|
|
297
|
+
// Half a chain header, a non-integer/overflowing counter: readable JSON, unusable link.
|
|
298
|
+
return { seq: 1, prevHash: EVENT_CHAIN_GENESIS_HASH, chainReset: true };
|
|
299
|
+
}
|
|
300
|
+
return { seq: parsed.seq + 1, prevHash: chainHashOf(stripEol(lastLine)) };
|
|
301
|
+
} catch {
|
|
302
|
+
return { seq: 1, prevHash: EVENT_CHAIN_GENESIS_HASH, chainReset: true };
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Append the chain fields to a record. Fields go LAST so readers that whitelist keys are unaffected. */
|
|
307
|
+
export function withChainFields<T extends object>(record: T, fields: ChainFields): T & ChainFields {
|
|
308
|
+
return {
|
|
309
|
+
...record,
|
|
310
|
+
seq: fields.seq,
|
|
311
|
+
prevHash: fields.prevHash,
|
|
312
|
+
...(fields.chainReset === true ? { chainReset: true as const } : {}),
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Serialise a run of records into chained lines (no trailing newline on each), continuing from
|
|
318
|
+
* `lastLine`. Used both by the appenders (1..3 records per prompt) and by the rewriters.
|
|
319
|
+
*/
|
|
320
|
+
export function chainRecordLines(
|
|
321
|
+
records: readonly object[],
|
|
322
|
+
lastLine: string | undefined,
|
|
323
|
+
opts: { readonly forceReset?: boolean } = {},
|
|
324
|
+
): string[] {
|
|
325
|
+
const out: string[] = [];
|
|
326
|
+
let prev = lastLine;
|
|
327
|
+
let force = opts.forceReset === true;
|
|
328
|
+
for (const rec of records) {
|
|
329
|
+
const line = JSON.stringify(withChainFields(rec, nextChainFields(prev, { forceReset: force })));
|
|
330
|
+
force = false; // only the FIRST record of the run opens the new segment
|
|
331
|
+
out.push(line);
|
|
332
|
+
prev = line;
|
|
333
|
+
}
|
|
334
|
+
return out;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Default event weight of a line (ADR-002 decision 4): the ledger itself weighs nothing, an
|
|
339
|
+
* aggregate weighs the reads it folded, anything else is one event.
|
|
340
|
+
*/
|
|
341
|
+
export function defaultEventWeight(record: Record<string, unknown>): number {
|
|
342
|
+
if (record['kind'] === EVENT_CHAIN_LEDGER_KIND) return 0;
|
|
343
|
+
if (record['kind'] === 'aggregate') {
|
|
344
|
+
const reads = record['reads'];
|
|
345
|
+
return typeof reads === 'number' && Number.isFinite(reads) && reads > 0 ? Math.floor(reads) : 0;
|
|
346
|
+
}
|
|
347
|
+
return 1;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Build the chained lines of a full rewrite: a {@link EventChainLedger} first, then the records.
|
|
352
|
+
*
|
|
353
|
+
* `sourceEvents` must be measured from the INPUT, before aggregation — a number derived from the
|
|
354
|
+
* output can never disagree with it, which is the whole point (ADR-002).
|
|
355
|
+
*/
|
|
356
|
+
export function chainRewrite(
|
|
357
|
+
records: readonly object[],
|
|
358
|
+
opts: {
|
|
359
|
+
readonly sourceEvents: number;
|
|
360
|
+
readonly droppedEvents?: number;
|
|
361
|
+
readonly compactedAt: string;
|
|
362
|
+
},
|
|
363
|
+
): string[] {
|
|
364
|
+
const sourceEvents = clampCount(opts.sourceEvents);
|
|
365
|
+
const droppedEvents = clampCount(opts.droppedEvents ?? 0);
|
|
366
|
+
const ledger: EventChainLedger = {
|
|
367
|
+
kind: EVENT_CHAIN_LEDGER_KIND,
|
|
368
|
+
sourceEvents,
|
|
369
|
+
droppedEvents,
|
|
370
|
+
throughSeq: records.length + 1,
|
|
371
|
+
compactedAt: opts.compactedAt,
|
|
372
|
+
};
|
|
373
|
+
return chainRecordLines([ledger, ...records], undefined);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function clampCount(value: unknown): number {
|
|
377
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** Total event weight of a JSONL text — the measurement a rewriter records as `sourceEvents`. */
|
|
381
|
+
export function eventWeightOfText(
|
|
382
|
+
text: string,
|
|
383
|
+
weightOf: (record: Record<string, unknown>) => number = defaultEventWeight,
|
|
384
|
+
): number {
|
|
385
|
+
let total = 0;
|
|
386
|
+
for (const line of chainLinesOf(text)) {
|
|
387
|
+
const parsed = parseLine(line);
|
|
388
|
+
if (parsed === undefined) continue;
|
|
389
|
+
const w = weightOf(parsed.record);
|
|
390
|
+
if (Number.isFinite(w) && w > 0) total += Math.floor(w);
|
|
391
|
+
}
|
|
392
|
+
return total;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
export interface VerifyEventChainOptions {
|
|
396
|
+
readonly eventWeight?: (record: Record<string, unknown>) => number;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
interface LedgerClaim {
|
|
400
|
+
readonly line: number;
|
|
401
|
+
readonly segment: number;
|
|
402
|
+
readonly fromSeq: number;
|
|
403
|
+
readonly throughSeq: number;
|
|
404
|
+
readonly sourceEvents: number;
|
|
405
|
+
readonly droppedEvents: number;
|
|
406
|
+
accounted: number;
|
|
407
|
+
/** Still awaiting the chained evidence up to `throughSeq`. */
|
|
408
|
+
open: boolean;
|
|
409
|
+
/** Set when the segment restarted (or the file ended) before the claim was satisfied. */
|
|
410
|
+
interruptedAtLine: number | null;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Verify a chained log. PURE: hand it the lines, get back every defect it can name.
|
|
415
|
+
*
|
|
416
|
+
* A file that has never been chained verifies OK with `preChainPrefix === lines` — records written
|
|
417
|
+
* before chaining existed are legal, and the uncovered prefix is reported as a count rather than
|
|
418
|
+
* flagged (FR-5). Once the chain starts, an unchained record after it is a hole (`BrokenLink`).
|
|
419
|
+
*/
|
|
420
|
+
export function verifyEventChain(
|
|
421
|
+
lines: readonly string[],
|
|
422
|
+
opts: VerifyEventChainOptions = {},
|
|
423
|
+
): EventChainVerification {
|
|
424
|
+
const weightOf = typeof opts.eventWeight === 'function' ? opts.eventWeight : defaultEventWeight;
|
|
425
|
+
const defects: EventChainDefect[] = [];
|
|
426
|
+
const claims: LedgerClaim[] = [];
|
|
427
|
+
|
|
428
|
+
// Index of the last non-empty line, so "torn TAIL" can be told from a torn record mid-file.
|
|
429
|
+
let lastNonEmpty = -1;
|
|
430
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
431
|
+
if (stripEol(lines[i] ?? '').trim() !== '') lastNonEmpty = i;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
let examined = 0;
|
|
435
|
+
let chained = 0;
|
|
436
|
+
let preChainPrefix = 0;
|
|
437
|
+
let resets = 0;
|
|
438
|
+
let chainStarted = false;
|
|
439
|
+
let segment = 0;
|
|
440
|
+
let prevLine: string | undefined;
|
|
441
|
+
let prevSeq: number | undefined;
|
|
442
|
+
let seen = new Set<number>();
|
|
443
|
+
// CROSS-SEGMENT duplicate CONTENT (Codex re-QE MED): the per-segment `seen` set resets on every
|
|
444
|
+
// re-anchor, so a whole duplicated `seq 1..N` segment produced only the one BrokenLink and the
|
|
445
|
+
// copies hid behind the restart. `(seq, line-hash)` pairs are tracked GLOBALLY: identical content
|
|
446
|
+
// repeating under the same seq is named wherever it lands — while AM-9's healthy-successor case
|
|
447
|
+
// (same seqs, DIFFERENT records) stays cascade-free.
|
|
448
|
+
const seenPairs = new Set<string>();
|
|
449
|
+
|
|
450
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
451
|
+
const line = stripEol(lines[i] ?? '');
|
|
452
|
+
if (line.trim() === '') continue;
|
|
453
|
+
examined += 1;
|
|
454
|
+
const lineNo = i + 1;
|
|
455
|
+
const parsed = parseLine(line);
|
|
456
|
+
|
|
457
|
+
if (parsed === undefined) {
|
|
458
|
+
defects.push({
|
|
459
|
+
kind: 'TornTail',
|
|
460
|
+
line: lineNo,
|
|
461
|
+
detail:
|
|
462
|
+
i === lastNonEmpty
|
|
463
|
+
? 'the last line is not a readable record — a partial write; the next append starts a marked segment'
|
|
464
|
+
: 'unreadable record mid-file (a torn write that was later appended past) — its content is lost',
|
|
465
|
+
});
|
|
466
|
+
prevLine = line; // its raw bytes are still what the next record hashed
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if (parsed.seq === undefined || parsed.prevHash === undefined) {
|
|
471
|
+
if (chainStarted) {
|
|
472
|
+
defects.push({
|
|
473
|
+
kind: 'BrokenLink',
|
|
474
|
+
line: lineNo,
|
|
475
|
+
detail: 'record has no chain fields but the chain already started — a hole in the chain',
|
|
476
|
+
});
|
|
477
|
+
} else {
|
|
478
|
+
preChainPrefix += 1;
|
|
479
|
+
}
|
|
480
|
+
prevLine = line;
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
chained += 1;
|
|
485
|
+
const isSegmentStart = parsed.prevHash === EVENT_CHAIN_GENESIS_HASH && parsed.seq === 1;
|
|
486
|
+
|
|
487
|
+
if (!chainStarted) {
|
|
488
|
+
chainStarted = true;
|
|
489
|
+
segment = 1;
|
|
490
|
+
seen = new Set<number>();
|
|
491
|
+
prevSeq = undefined;
|
|
492
|
+
if (!isSegmentStart) {
|
|
493
|
+
// The chain's very first link must anchor to genesis; anything else lost its predecessors.
|
|
494
|
+
defects.push({
|
|
495
|
+
kind: 'BrokenLink',
|
|
496
|
+
line: lineNo,
|
|
497
|
+
seq: parsed.seq,
|
|
498
|
+
detail: `the first chained record does not start a segment (seq ${parsed.seq}, prevHash ${parsed.prevHash}) — earlier chained records were lost`,
|
|
499
|
+
});
|
|
500
|
+
} else if (parsed.reset) {
|
|
501
|
+
// The chain's first link is itself a recorded discontinuity (the writer met a torn tail
|
|
502
|
+
// before any chained record existed). Still a restart, still counted.
|
|
503
|
+
resets += 1;
|
|
504
|
+
}
|
|
505
|
+
} else if (parsed.reset) {
|
|
506
|
+
if (!isSegmentStart) {
|
|
507
|
+
defects.push({
|
|
508
|
+
kind: 'BrokenLink',
|
|
509
|
+
line: lineNo,
|
|
510
|
+
seq: parsed.seq,
|
|
511
|
+
detail: 'record is marked chainReset but does not start a segment (needs seq 1 and the genesis prevHash)',
|
|
512
|
+
});
|
|
513
|
+
} else {
|
|
514
|
+
// AM-4 (Codex QE HIGH-4): a marked restart does NOT absolve an unfinished claim. A rewrite
|
|
515
|
+
// could emit `ledger, two records, RESET, an aggregate of 100` and have the 100 fall outside
|
|
516
|
+
// every claim's segment — laundering events through a legal-looking discontinuity. Two
|
|
517
|
+
// independent lines close it: this one, which names the restart as the cause, and the
|
|
518
|
+
// end-of-file sweep below, which reports any claim left open however the segment ended.
|
|
519
|
+
// Each has its own test; removing either one turns a report RED.
|
|
520
|
+
for (const c of claims) {
|
|
521
|
+
if (c.open && c.segment === segment && c.interruptedAtLine === null) c.interruptedAtLine = lineNo;
|
|
522
|
+
}
|
|
523
|
+
resets += 1;
|
|
524
|
+
segment += 1;
|
|
525
|
+
seen = new Set<number>();
|
|
526
|
+
prevSeq = undefined;
|
|
527
|
+
}
|
|
528
|
+
} else {
|
|
529
|
+
// A normal link: its prevHash must be the hash of the line above it.
|
|
530
|
+
const expected = prevLine === undefined ? EVENT_CHAIN_GENESIS_HASH : chainHashOf(prevLine);
|
|
531
|
+
const unmarkedRestart = isSegmentStart && parsed.prevHash !== expected;
|
|
532
|
+
if (parsed.prevHash !== expected) {
|
|
533
|
+
defects.push({
|
|
534
|
+
kind: 'BrokenLink',
|
|
535
|
+
line: lineNo,
|
|
536
|
+
seq: parsed.seq,
|
|
537
|
+
detail: unmarkedRestart
|
|
538
|
+
? 'segment restart without a chainReset marker — a break the writer did not record'
|
|
539
|
+
: `prevHash ${parsed.prevHash} does not match the preceding line (${expected})`,
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
if (unmarkedRestart) {
|
|
543
|
+
// RE-ANCHOR. Observed live 2026-07-29: one incident (a single unchained record slipped in
|
|
544
|
+
// from the hook's degraded fallback path) produced FIVE defects — the break itself plus a
|
|
545
|
+
// DuplicateSeq for every healthy record that followed, because the new segment's seq 1,2,3
|
|
546
|
+
// collided with the old segment's. A cascade buries the incident it is reporting. The break
|
|
547
|
+
// is named once, above; from here the records are judged on their own segment.
|
|
548
|
+
//
|
|
549
|
+
// An unmarked restart interrupts open claims exactly as a marked one does. Honest scope of
|
|
550
|
+
// that line: DETECTION already comes from the end-of-file sweep (a claim left open is
|
|
551
|
+
// reported however the segment ended), so this is what makes the report name the CAUSE and
|
|
552
|
+
// its line rather than saying only "the chain ends before it".
|
|
553
|
+
for (const c of claims) {
|
|
554
|
+
if (c.open && c.segment === segment && c.interruptedAtLine === null) c.interruptedAtLine = lineNo;
|
|
555
|
+
}
|
|
556
|
+
segment += 1;
|
|
557
|
+
seen = new Set<number>();
|
|
558
|
+
prevSeq = undefined;
|
|
559
|
+
} else if (prevSeq !== undefined) {
|
|
560
|
+
// ORDER IS LOAD-BEARING. Equality first: two writers that read the same tail both mint
|
|
561
|
+
// `prevSeq + 1`, and calling that "non-monotonic" would bury the race under the wrong name.
|
|
562
|
+
// Below-previous is the restart shape, which deserves the name that says a step went
|
|
563
|
+
// backwards. A repeat that is neither adjacent nor below can only follow an earlier break.
|
|
564
|
+
if (parsed.seq === prevSeq) {
|
|
565
|
+
defects.push({
|
|
566
|
+
kind: 'DuplicateSeq',
|
|
567
|
+
line: lineNo,
|
|
568
|
+
seq: parsed.seq,
|
|
569
|
+
detail: `seq ${parsed.seq} repeats the previous record — a duplicated record, or two writers racing on the same tail`,
|
|
570
|
+
});
|
|
571
|
+
} else if (parsed.seq < prevSeq) {
|
|
572
|
+
defects.push({
|
|
573
|
+
kind: 'NonMonotonicSeq',
|
|
574
|
+
line: lineNo,
|
|
575
|
+
seq: parsed.seq,
|
|
576
|
+
detail: `seq ${parsed.seq} follows ${prevSeq} without a recorded chainReset`,
|
|
577
|
+
});
|
|
578
|
+
} else if (seen.has(parsed.seq)) {
|
|
579
|
+
defects.push({
|
|
580
|
+
kind: 'DuplicateSeq',
|
|
581
|
+
line: lineNo,
|
|
582
|
+
seq: parsed.seq,
|
|
583
|
+
detail: `seq ${parsed.seq} already appears in this segment`,
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const contentPair = `${parsed.seq}|${chainHashOf(line)}`;
|
|
590
|
+
if (seenPairs.has(contentPair)) {
|
|
591
|
+
defects.push({
|
|
592
|
+
kind: 'DuplicateSeq',
|
|
593
|
+
line: lineNo,
|
|
594
|
+
seq: parsed.seq,
|
|
595
|
+
detail: 'cross-segment duplicate content — the same seq with an identical record appeared earlier (a re-anchored restart does not absolve copies)',
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
seenPairs.add(contentPair);
|
|
599
|
+
seen.add(parsed.seq);
|
|
600
|
+
prevSeq = parsed.seq;
|
|
601
|
+
prevLine = line;
|
|
602
|
+
|
|
603
|
+
// Accounting FIRST, then registration: a ledger never counts itself.
|
|
604
|
+
for (const c of claims) {
|
|
605
|
+
if (c.segment === segment && parsed.seq > c.fromSeq && parsed.seq <= c.throughSeq) {
|
|
606
|
+
const w = weightOf(parsed.record);
|
|
607
|
+
if (Number.isFinite(w) && w > 0) c.accounted += Math.floor(w);
|
|
608
|
+
}
|
|
609
|
+
if (c.open && c.segment === segment && parsed.seq >= c.throughSeq) c.open = false;
|
|
610
|
+
}
|
|
611
|
+
const ledger = readLedgerClaim(parsed.record, lineNo, segment, parsed.seq);
|
|
612
|
+
if (ledger !== undefined) {
|
|
613
|
+
if ('defect' in ledger) defects.push(ledger.defect);
|
|
614
|
+
else claims.push(ledger.claim);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
for (const c of claims) {
|
|
619
|
+
if (c.open && c.interruptedAtLine === null) c.interruptedAtLine = -1; // ran out of file
|
|
620
|
+
if (c.interruptedAtLine !== null) {
|
|
621
|
+
defects.push({
|
|
622
|
+
kind: 'ClaimInterrupted',
|
|
623
|
+
line: c.line,
|
|
624
|
+
detail:
|
|
625
|
+
c.interruptedAtLine === -1
|
|
626
|
+
? `the rewrite claimed to write through seq ${c.throughSeq}, but the chain ends before it — the evidence it accounts for is not in the file`
|
|
627
|
+
: `the rewrite claimed to write through seq ${c.throughSeq}, but the segment restarted at line ${c.interruptedAtLine} first — a marked reset does not satisfy a claim`,
|
|
628
|
+
});
|
|
629
|
+
continue; // an unfinished claim has no arithmetic to check; reporting both would be noise
|
|
630
|
+
}
|
|
631
|
+
// AM-3: the raw equality, no clamps. `droppedEvents` is what the budget deliberately discarded,
|
|
632
|
+
// so a rewrite must account for exactly what it did not drop.
|
|
633
|
+
if (c.droppedEvents < 0 || c.droppedEvents > c.sourceEvents) {
|
|
634
|
+
defects.push({
|
|
635
|
+
kind: 'LedgerImbalance',
|
|
636
|
+
line: c.line,
|
|
637
|
+
detail: `droppedEvents ${c.droppedEvents} is outside [0, sourceEvents=${c.sourceEvents}] — a rewrite cannot discard events it never read`,
|
|
638
|
+
});
|
|
639
|
+
continue;
|
|
640
|
+
}
|
|
641
|
+
const total = c.accounted + c.droppedEvents;
|
|
642
|
+
if (total > c.sourceEvents) {
|
|
643
|
+
defects.push({
|
|
644
|
+
kind: 'DoubleCounted',
|
|
645
|
+
line: c.line,
|
|
646
|
+
detail: `the rewrite accounts for ${c.accounted} event(s) + ${c.droppedEvents} dropped = ${total}, but measured ${c.sourceEvents} in its input — an event is counted more than once (the 2 → 4 → 6 class)`,
|
|
647
|
+
});
|
|
648
|
+
} else if (total < c.sourceEvents) {
|
|
649
|
+
defects.push({
|
|
650
|
+
kind: 'LedgerImbalance',
|
|
651
|
+
line: c.line,
|
|
652
|
+
detail: `the rewrite accounts for ${c.accounted} event(s) + ${c.droppedEvents} dropped = ${total}, but claimed ${c.sourceEvents} — records it carried are unaccounted for`,
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
defects.sort((a, b) => a.line - b.line || a.kind.localeCompare(b.kind));
|
|
658
|
+
|
|
659
|
+
return {
|
|
660
|
+
ok: defects.length === 0,
|
|
661
|
+
lines: examined,
|
|
662
|
+
chained,
|
|
663
|
+
preChainPrefix,
|
|
664
|
+
resets,
|
|
665
|
+
lastSeq: prevSeq ?? null,
|
|
666
|
+
defects,
|
|
667
|
+
scope: EVENT_CHAIN_SCOPE,
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Read a ledger line into a CLAIM, or into a named defect. Nothing in between.
|
|
673
|
+
*
|
|
674
|
+
* AM-5 (Codex QE HIGH-5): the previous version returned `undefined` for a malformed ledger, which
|
|
675
|
+
* meant a hand-edited or truncated ledger DISABLED the accounting check in silence — the strongest
|
|
676
|
+
* check in the module, switched off by damaging the very line that carries it. An invalid ledger in
|
|
677
|
+
* the chained region is now `MalformedLedger`.
|
|
678
|
+
*
|
|
679
|
+
* AM-3 (Codex QE HIGH-3): NO CLAMPS HERE. `Math.max(0, source - dropped)` accepted `dropped > source`
|
|
680
|
+
* and quietly turned an impossible claim into a satisfiable one. The verifier enforces the raw
|
|
681
|
+
* equality; the clamp belongs in the WRITER, which must never emit a negative in the first place.
|
|
682
|
+
*/
|
|
683
|
+
function readLedgerClaim(
|
|
684
|
+
record: Record<string, unknown>,
|
|
685
|
+
line: number,
|
|
686
|
+
segment: number,
|
|
687
|
+
seq: number,
|
|
688
|
+
): { claim: LedgerClaim } | { defect: EventChainDefect } | undefined {
|
|
689
|
+
if (record['kind'] !== EVENT_CHAIN_LEDGER_KIND) return undefined;
|
|
690
|
+
const source = record['sourceEvents'];
|
|
691
|
+
const dropped = record['droppedEvents'];
|
|
692
|
+
const through = record['throughSeq'];
|
|
693
|
+
const bad = (detail: string): { defect: EventChainDefect } => ({
|
|
694
|
+
defect: { kind: 'MalformedLedger', line, seq, detail },
|
|
695
|
+
});
|
|
696
|
+
// Every numeric read from a log gets a finite/integer check: a `1e400` in a hand-edited file
|
|
697
|
+
// parses to Infinity, and an Infinity ceiling silently disables the check it is supposed to be.
|
|
698
|
+
if (typeof source !== 'number' || !Number.isSafeInteger(source) || source < 0) {
|
|
699
|
+
return bad(`sourceEvents is not a non-negative integer (${describe(source)}) — the accounting claim is unusable`);
|
|
700
|
+
}
|
|
701
|
+
if (typeof through !== 'number' || !Number.isSafeInteger(through) || through < seq) {
|
|
702
|
+
return bad(`throughSeq is not an integer >= this record's own seq (${describe(through)}) — the claim covers no readable range`);
|
|
703
|
+
}
|
|
704
|
+
if (dropped !== undefined && (typeof dropped !== 'number' || !Number.isSafeInteger(dropped))) {
|
|
705
|
+
return bad(`droppedEvents is not an integer (${describe(dropped)})`);
|
|
706
|
+
}
|
|
707
|
+
const droppedEvents = typeof dropped === 'number' ? dropped : 0;
|
|
708
|
+
return {
|
|
709
|
+
claim: {
|
|
710
|
+
line,
|
|
711
|
+
segment,
|
|
712
|
+
fromSeq: seq,
|
|
713
|
+
throughSeq: through,
|
|
714
|
+
sourceEvents: source,
|
|
715
|
+
droppedEvents,
|
|
716
|
+
accounted: 0,
|
|
717
|
+
open: through > seq,
|
|
718
|
+
interruptedAtLine: null,
|
|
719
|
+
},
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
function describe(value: unknown): string {
|
|
724
|
+
if (typeof value === 'number') return Number.isFinite(value) ? String(value) : 'non-finite';
|
|
725
|
+
return value === undefined ? 'absent' : typeof value;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// ── Guarded rewrite (AM-1 · Codex QE CRITICAL-1) ────────────────────────────────────────────────
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* What a rewriter must observe about the live file to know nobody appended behind its back.
|
|
732
|
+
*
|
|
733
|
+
* A read → compute → temp → rename rewrite has a window: an append landing after the read is
|
|
734
|
+
* OVERWRITTEN by the rename, and — because it was never in the input the ledger measured — verify
|
|
735
|
+
* reports the result as perfectly clean. Silent loss, invisible to the very check built to see it.
|
|
736
|
+
*/
|
|
737
|
+
export interface RewriteSnapshot {
|
|
738
|
+
readonly bytes: number;
|
|
739
|
+
readonly lines: number;
|
|
740
|
+
readonly lastLine: string | undefined;
|
|
741
|
+
/** FNV over the WHOLE text: bytes+lines+lastLine miss an equal-length edit to a non-tail line
|
|
742
|
+
* (Codex re-QE HIGH) — only a full-content hash makes "unchanged" mean unchanged. */
|
|
743
|
+
readonly textHash: string;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
export function rewriteSnapshot(text: string): RewriteSnapshot {
|
|
747
|
+
const safe = typeof text === 'string' ? text : '';
|
|
748
|
+
const lines = chainLinesOf(safe);
|
|
749
|
+
return { bytes: safe.length, lines: lines.length, lastLine: lines[lines.length - 1], textHash: chainHashOf(safe) };
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/** True when the live file is byte-for-byte where the snapshot left it. */
|
|
753
|
+
export function rewriteSnapshotUnchanged(a: RewriteSnapshot, b: RewriteSnapshot): boolean {
|
|
754
|
+
return a.textHash === b.textHash && a.bytes === b.bytes && a.lines === b.lines && a.lastLine === b.lastLine;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
export type GuardedRewriteStatus =
|
|
758
|
+
/** The rewrite landed. */
|
|
759
|
+
| 'rewritten'
|
|
760
|
+
/** Another rewriter holds the lock — this one did nothing, which is correct. */
|
|
761
|
+
| 'locked'
|
|
762
|
+
/** Appends kept landing mid-rewrite; nothing was renamed. NO DATA WAS LOST. */
|
|
763
|
+
| 'raced'
|
|
764
|
+
/** The input's chain is already defective; laundering it into a clean rewrite is refused (AM-2). */
|
|
765
|
+
| 'refused-dirty'
|
|
766
|
+
/** The file could not be read. */
|
|
767
|
+
| 'unreadable'
|
|
768
|
+
/** The rewrite produced nothing usable; the file is left alone. */
|
|
769
|
+
| 'empty';
|
|
770
|
+
|
|
771
|
+
export interface GuardedRewriteResult {
|
|
772
|
+
readonly status: GuardedRewriteStatus;
|
|
773
|
+
readonly attempts: number;
|
|
774
|
+
/** Populated for `refused-dirty` so the caller can say WHY, loudly. */
|
|
775
|
+
readonly defects: readonly EventChainDefect[];
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
export interface GuardedRewriteIo {
|
|
779
|
+
/** Read the whole live log. `undefined` ⇒ unreadable. */
|
|
780
|
+
readonly read: () => string | undefined;
|
|
781
|
+
/** Write to a temp file and atomically rename it over the log. */
|
|
782
|
+
readonly replace: (text: string) => void;
|
|
783
|
+
/** Take the rewrite lock. `false` ⇒ someone else owns it. */
|
|
784
|
+
readonly acquireLock: () => boolean;
|
|
785
|
+
readonly releaseLock: () => void;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/** A rewrite proposal: the new text, plus whatever made the rewriter refuse. */
|
|
789
|
+
export interface RewriteProposal {
|
|
790
|
+
readonly text: string;
|
|
791
|
+
readonly refusedDirty?: boolean;
|
|
792
|
+
readonly defects?: readonly EventChainDefect[];
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
export const DEFAULT_REWRITE_ATTEMPTS = 3;
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* Run a whole-file rewrite so that a concurrent append can never be silently overwritten.
|
|
799
|
+
*
|
|
800
|
+
* Three guards, in order:
|
|
801
|
+
* 1. an exclusive lock, so two rewriters cannot interleave at all;
|
|
802
|
+
* 2. a re-read of the live file after computing the new text and BEFORE the rename — if it moved,
|
|
803
|
+
* the attempt is ABANDONED and retried against the newer file, so the append is folded in
|
|
804
|
+
* rather than lost;
|
|
805
|
+
* 3. a bounded attempt count — a log under constant append is left alone, never truncated.
|
|
806
|
+
*
|
|
807
|
+
* HONEST RESIDUAL (state it, do not paper over it): the re-read narrows the window to the interval
|
|
808
|
+
* between the final read and the `rename` syscall; it does not close it, because a conditional
|
|
809
|
+
* rename is not an operation the filesystem offers. What it removes is the wide window (the whole
|
|
810
|
+
* parse + compact + write) that made loss likely rather than rare.
|
|
811
|
+
*
|
|
812
|
+
* Pure orchestration — all IO is injected, which is what makes the race testable deterministically.
|
|
813
|
+
*/
|
|
814
|
+
export function guardedRewrite(
|
|
815
|
+
io: GuardedRewriteIo,
|
|
816
|
+
propose: (text: string) => RewriteProposal,
|
|
817
|
+
opts: { readonly attempts?: number } = {},
|
|
818
|
+
): GuardedRewriteResult {
|
|
819
|
+
const max =
|
|
820
|
+
typeof opts.attempts === 'number' && Number.isFinite(opts.attempts) && opts.attempts >= 1
|
|
821
|
+
? Math.floor(opts.attempts)
|
|
822
|
+
: DEFAULT_REWRITE_ATTEMPTS;
|
|
823
|
+
if (!io.acquireLock()) return { status: 'locked', attempts: 0, defects: [] };
|
|
824
|
+
let attempts = 0;
|
|
825
|
+
try {
|
|
826
|
+
for (let i = 0; i < max; i += 1) {
|
|
827
|
+
attempts += 1;
|
|
828
|
+
const before = io.read();
|
|
829
|
+
if (typeof before !== 'string') return { status: 'unreadable', attempts, defects: [] };
|
|
830
|
+
const snapshot = rewriteSnapshot(before);
|
|
831
|
+
const proposal = propose(before);
|
|
832
|
+
if (proposal.refusedDirty === true) {
|
|
833
|
+
return { status: 'refused-dirty', attempts, defects: proposal.defects ?? [] };
|
|
834
|
+
}
|
|
835
|
+
if (typeof proposal.text !== 'string' || proposal.text === '') {
|
|
836
|
+
return { status: 'empty', attempts, defects: [] };
|
|
837
|
+
}
|
|
838
|
+
const after = io.read();
|
|
839
|
+
if (typeof after !== 'string') return { status: 'unreadable', attempts, defects: [] };
|
|
840
|
+
if (!rewriteSnapshotUnchanged(snapshot, rewriteSnapshot(after))) continue; // someone appended
|
|
841
|
+
io.replace(proposal.text);
|
|
842
|
+
return { status: 'rewritten', attempts, defects: [] };
|
|
843
|
+
}
|
|
844
|
+
return { status: 'raced', attempts, defects: [] };
|
|
845
|
+
} finally {
|
|
846
|
+
io.releaseLock();
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/** Convenience wrapper for callers holding the whole file. */
|
|
851
|
+
export function verifyEventChainText(text: string, opts: VerifyEventChainOptions = {}): EventChainVerification {
|
|
852
|
+
return verifyEventChain(chainLinesOf(typeof text === 'string' ? text : ''), opts);
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/**
|
|
856
|
+
* One line an operator can read. Always carries {@link EVENT_CHAIN_SCOPE} so no surface can print a
|
|
857
|
+
* chain verdict without also printing what it does and does not mean.
|
|
858
|
+
*/
|
|
859
|
+
export function renderEventChainVerification(v: EventChainVerification, label: string): string {
|
|
860
|
+
const head = v.ok ? 'chain OK' : `chain FAILED (${v.defects.length} defect(s))`;
|
|
861
|
+
const parts = [
|
|
862
|
+
`${label}: ${head}`,
|
|
863
|
+
`${v.chained} chained`,
|
|
864
|
+
`${v.preChainPrefix} pre-chain (uncovered)`,
|
|
865
|
+
];
|
|
866
|
+
if (v.resets > 0) parts.push(`${v.resets} recorded restart(s)`);
|
|
867
|
+
const kinds = [...new Set(v.defects.map((d) => d.kind))];
|
|
868
|
+
if (kinds.length > 0) parts.push(kinds.join('/'));
|
|
869
|
+
return `${parts.join(' · ')} — ${v.scope}`;
|
|
870
|
+
}
|