@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,663 @@
|
|
|
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
|
+
/** The one sentence that states what the chain is and is not. Printed by every verify surface. */
|
|
33
|
+
export const EVENT_CHAIN_SCOPE = 'corruption detection for our own bugs (compaction, torn writes, races) — FNV-1a is not cryptography, ' +
|
|
34
|
+
'the threat model has no adversary, and anyone who can edit the log can recompute the chain';
|
|
35
|
+
/** `prevHash` of the first record of a chain segment. */
|
|
36
|
+
export const EVENT_CHAIN_GENESIS_HASH = '00000000';
|
|
37
|
+
/** How many bytes of a log's tail a writer needs to read to find the last line. */
|
|
38
|
+
export const EVENT_CHAIN_TAIL_BYTES = 65_536;
|
|
39
|
+
/**
|
|
40
|
+
* Bytes charged per line for its chain fields when a rewriter budgets its output. Measured against
|
|
41
|
+
* the widest realistic shape: `,"seq":<up to 15 digits>,"prevHash":"xxxxxxxx",` plus the reset
|
|
42
|
+
* marker — deliberately generous, because under-charging would blow a byte cap.
|
|
43
|
+
*/
|
|
44
|
+
export const EVENT_CHAIN_FIELD_OVERHEAD_BYTES = 64;
|
|
45
|
+
/** Marker record a rewriter puts first so its output can be checked against its input (ADR-002). */
|
|
46
|
+
export const EVENT_CHAIN_LEDGER_KIND = 'chain-ledger';
|
|
47
|
+
const HEX8 = /^[0-9a-f]{8}$/;
|
|
48
|
+
/**
|
|
49
|
+
* The defect vocabulary, as data. ruview's ADR-010 names one more class that this list deliberately
|
|
50
|
+
* omits — a class whose name would assert exactly the promise {@link EVENT_CHAIN_SCOPE} refuses —
|
|
51
|
+
* and a test asserts no kind here ever grows that vocabulary.
|
|
52
|
+
*/
|
|
53
|
+
export const EVENT_CHAIN_DEFECT_KINDS = [
|
|
54
|
+
'BrokenLink',
|
|
55
|
+
'DuplicateSeq',
|
|
56
|
+
'NonMonotonicSeq',
|
|
57
|
+
'TornTail',
|
|
58
|
+
'DoubleCounted',
|
|
59
|
+
'LedgerImbalance',
|
|
60
|
+
'MalformedLedger',
|
|
61
|
+
'ClaimInterrupted',
|
|
62
|
+
];
|
|
63
|
+
/**
|
|
64
|
+
* FNV-1a, 32 bits, over both UTF-16 bytes of every code unit in order.
|
|
65
|
+
*
|
|
66
|
+
* Both bytes, unconditionally: folding with `& 0xff` would make every Cyrillic pair that shares a
|
|
67
|
+
* low byte collide, and these logs carry Russian prompts. 32 bits gives roughly a 1-in-4.3e9
|
|
68
|
+
* accidental-collision chance per link, which is the right size for detecting a bug and the wrong
|
|
69
|
+
* size for detecting an enemy — see {@link EVENT_CHAIN_SCOPE}.
|
|
70
|
+
*/
|
|
71
|
+
export function fnv1a32(text) {
|
|
72
|
+
let h = 0x811c9dc5;
|
|
73
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
74
|
+
const c = text.charCodeAt(i);
|
|
75
|
+
h = Math.imul(h ^ (c & 0xff), 0x01000193) >>> 0;
|
|
76
|
+
h = Math.imul(h ^ ((c >>> 8) & 0xff), 0x01000193) >>> 0;
|
|
77
|
+
}
|
|
78
|
+
return h.toString(16).padStart(8, '0');
|
|
79
|
+
}
|
|
80
|
+
/** Strip the line terminator a reader may have left, so writer and verifier hash the same string. */
|
|
81
|
+
function stripEol(line) {
|
|
82
|
+
return line.endsWith('\r') ? line.slice(0, -1) : line;
|
|
83
|
+
}
|
|
84
|
+
/** The hash a record's successor must carry. Input is the line AS WRITTEN (ADR-001 decision 2). */
|
|
85
|
+
export function chainHashOf(line) {
|
|
86
|
+
return fnv1a32(stripEol(line));
|
|
87
|
+
}
|
|
88
|
+
/** Non-empty lines of a JSONL text, terminators stripped. The one splitter both sides use. */
|
|
89
|
+
export function chainLinesOf(text) {
|
|
90
|
+
const out = [];
|
|
91
|
+
for (const raw of text.split('\n')) {
|
|
92
|
+
const line = stripEol(raw);
|
|
93
|
+
if (line.trim() !== '')
|
|
94
|
+
out.push(line);
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Last usable line of a log's TAIL chunk — all a writer needs to extend the chain (FR-2).
|
|
100
|
+
*
|
|
101
|
+
* When the chunk does not start at byte 0 its first line may be a fragment; with more than one line
|
|
102
|
+
* present the fragment is simply not the last one, and with exactly one line the caller is looking
|
|
103
|
+
* at a single record longer than {@link EVENT_CHAIN_TAIL_BYTES}, which no writer here produces —
|
|
104
|
+
* that returns `undefined`, and the caller starts a marked segment rather than chaining onto a guess.
|
|
105
|
+
*/
|
|
106
|
+
export function lastChainLine(tailText, opts = {}) {
|
|
107
|
+
if (typeof tailText !== 'string')
|
|
108
|
+
return undefined;
|
|
109
|
+
const lines = chainLinesOf(tailText);
|
|
110
|
+
if (lines.length === 0)
|
|
111
|
+
return undefined;
|
|
112
|
+
if (opts.partial === true && !tailText.includes('\n'))
|
|
113
|
+
return undefined;
|
|
114
|
+
return lines[lines.length - 1];
|
|
115
|
+
}
|
|
116
|
+
/** Read a tail chunk into the facts an appender needs. `partial` ⇒ the chunk starts mid-file. */
|
|
117
|
+
export function readTailInfo(tailText, opts = {}) {
|
|
118
|
+
const text = typeof tailText === 'string' ? tailText : '';
|
|
119
|
+
const lastLine = lastChainLine(text, opts);
|
|
120
|
+
return {
|
|
121
|
+
lastLine,
|
|
122
|
+
endsWithNewline: text === '' || text.endsWith('\n'),
|
|
123
|
+
unreadable: lastLine === undefined && text.trim() !== '',
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
/** An empty log — what an appender assumes when the file is absent. */
|
|
127
|
+
export const EMPTY_LOG_TAIL = { lastLine: undefined, endsWithNewline: true, unreadable: false };
|
|
128
|
+
/**
|
|
129
|
+
* The exact text to append for a run of records: chained, newline-terminated, and preceded by a
|
|
130
|
+
* newline when the file ends mid-line. THE one place that knows how to extend one of these logs —
|
|
131
|
+
* the recall hook and the guard audit must not each carry their own copy of this reasoning.
|
|
132
|
+
*/
|
|
133
|
+
export function appendChainedLines(records, tail = EMPTY_LOG_TAIL) {
|
|
134
|
+
// AM-6: an unreadable tail is a TORN tail, never an empty one — it must produce a MARKED segment.
|
|
135
|
+
const lines = chainRecordLines(records, tail.lastLine, { forceReset: tail.unreadable === true });
|
|
136
|
+
if (lines.length === 0)
|
|
137
|
+
return '';
|
|
138
|
+
return `${tail.endsWithNewline === false ? '\n' : ''}${lines.join('\n')}\n`;
|
|
139
|
+
}
|
|
140
|
+
function parseLine(line) {
|
|
141
|
+
let value;
|
|
142
|
+
try {
|
|
143
|
+
value = JSON.parse(line);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
149
|
+
return undefined;
|
|
150
|
+
const record = value;
|
|
151
|
+
const rawSeq = record['seq'];
|
|
152
|
+
const rawPrev = record['prevHash'];
|
|
153
|
+
const seq = typeof rawSeq === 'number' && Number.isSafeInteger(rawSeq) && rawSeq >= 1 ? rawSeq : undefined;
|
|
154
|
+
const prevHash = typeof rawPrev === 'string' && HEX8.test(rawPrev) ? rawPrev : undefined;
|
|
155
|
+
return { record, seq, prevHash, reset: record['chainReset'] === true };
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* The chain fields the next appended record must carry, derived from the last line ALONE.
|
|
159
|
+
*
|
|
160
|
+
* TOTAL by construction — it cannot throw for any input, because the apply-leg hook's top safety
|
|
161
|
+
* property is never-block (ADR-001 D2). Every unreadable shape resolves the same way: start a fresh
|
|
162
|
+
* segment AND record the discontinuity, so a break is visible instead of silently healed (D4).
|
|
163
|
+
*
|
|
164
|
+
* - no last line (new/empty file) → genesis, no marker: this is a chain START
|
|
165
|
+
* - last line is an unchained record → genesis, no marker: the chain starts mid-file (FR-5)
|
|
166
|
+
* - last line is a chained record → `seq + 1`, linked to that line's hash
|
|
167
|
+
* - last line is unreadable / has a bad `seq` → genesis + `chainReset: true` (FR-3)
|
|
168
|
+
*/
|
|
169
|
+
export function nextChainFields(lastLine, opts = {}) {
|
|
170
|
+
try {
|
|
171
|
+
// AM-6: the caller saw a non-empty file it could not read a record from. That is a torn tail,
|
|
172
|
+
// and a torn tail is ALWAYS a marked segment — never a silent genesis.
|
|
173
|
+
if (opts.forceReset === true) {
|
|
174
|
+
return { seq: 1, prevHash: EVENT_CHAIN_GENESIS_HASH, chainReset: true };
|
|
175
|
+
}
|
|
176
|
+
if (typeof lastLine !== 'string' || lastLine.trim() === '') {
|
|
177
|
+
return { seq: 1, prevHash: EVENT_CHAIN_GENESIS_HASH };
|
|
178
|
+
}
|
|
179
|
+
const parsed = parseLine(stripEol(lastLine));
|
|
180
|
+
if (parsed === undefined) {
|
|
181
|
+
return { seq: 1, prevHash: EVENT_CHAIN_GENESIS_HASH, chainReset: true };
|
|
182
|
+
}
|
|
183
|
+
if (parsed.seq === undefined && parsed.prevHash === undefined) {
|
|
184
|
+
// A pre-chain record: this append is the first link, not a break.
|
|
185
|
+
return { seq: 1, prevHash: EVENT_CHAIN_GENESIS_HASH };
|
|
186
|
+
}
|
|
187
|
+
if (parsed.seq === undefined || parsed.prevHash === undefined || parsed.seq >= Number.MAX_SAFE_INTEGER) {
|
|
188
|
+
// Half a chain header, a non-integer/overflowing counter: readable JSON, unusable link.
|
|
189
|
+
return { seq: 1, prevHash: EVENT_CHAIN_GENESIS_HASH, chainReset: true };
|
|
190
|
+
}
|
|
191
|
+
return { seq: parsed.seq + 1, prevHash: chainHashOf(stripEol(lastLine)) };
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
return { seq: 1, prevHash: EVENT_CHAIN_GENESIS_HASH, chainReset: true };
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
/** Append the chain fields to a record. Fields go LAST so readers that whitelist keys are unaffected. */
|
|
198
|
+
export function withChainFields(record, fields) {
|
|
199
|
+
return {
|
|
200
|
+
...record,
|
|
201
|
+
seq: fields.seq,
|
|
202
|
+
prevHash: fields.prevHash,
|
|
203
|
+
...(fields.chainReset === true ? { chainReset: true } : {}),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Serialise a run of records into chained lines (no trailing newline on each), continuing from
|
|
208
|
+
* `lastLine`. Used both by the appenders (1..3 records per prompt) and by the rewriters.
|
|
209
|
+
*/
|
|
210
|
+
export function chainRecordLines(records, lastLine, opts = {}) {
|
|
211
|
+
const out = [];
|
|
212
|
+
let prev = lastLine;
|
|
213
|
+
let force = opts.forceReset === true;
|
|
214
|
+
for (const rec of records) {
|
|
215
|
+
const line = JSON.stringify(withChainFields(rec, nextChainFields(prev, { forceReset: force })));
|
|
216
|
+
force = false; // only the FIRST record of the run opens the new segment
|
|
217
|
+
out.push(line);
|
|
218
|
+
prev = line;
|
|
219
|
+
}
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Default event weight of a line (ADR-002 decision 4): the ledger itself weighs nothing, an
|
|
224
|
+
* aggregate weighs the reads it folded, anything else is one event.
|
|
225
|
+
*/
|
|
226
|
+
export function defaultEventWeight(record) {
|
|
227
|
+
if (record['kind'] === EVENT_CHAIN_LEDGER_KIND)
|
|
228
|
+
return 0;
|
|
229
|
+
if (record['kind'] === 'aggregate') {
|
|
230
|
+
const reads = record['reads'];
|
|
231
|
+
return typeof reads === 'number' && Number.isFinite(reads) && reads > 0 ? Math.floor(reads) : 0;
|
|
232
|
+
}
|
|
233
|
+
return 1;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Build the chained lines of a full rewrite: a {@link EventChainLedger} first, then the records.
|
|
237
|
+
*
|
|
238
|
+
* `sourceEvents` must be measured from the INPUT, before aggregation — a number derived from the
|
|
239
|
+
* output can never disagree with it, which is the whole point (ADR-002).
|
|
240
|
+
*/
|
|
241
|
+
export function chainRewrite(records, opts) {
|
|
242
|
+
const sourceEvents = clampCount(opts.sourceEvents);
|
|
243
|
+
const droppedEvents = clampCount(opts.droppedEvents ?? 0);
|
|
244
|
+
const ledger = {
|
|
245
|
+
kind: EVENT_CHAIN_LEDGER_KIND,
|
|
246
|
+
sourceEvents,
|
|
247
|
+
droppedEvents,
|
|
248
|
+
throughSeq: records.length + 1,
|
|
249
|
+
compactedAt: opts.compactedAt,
|
|
250
|
+
};
|
|
251
|
+
return chainRecordLines([ledger, ...records], undefined);
|
|
252
|
+
}
|
|
253
|
+
function clampCount(value) {
|
|
254
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
|
|
255
|
+
}
|
|
256
|
+
/** Total event weight of a JSONL text — the measurement a rewriter records as `sourceEvents`. */
|
|
257
|
+
export function eventWeightOfText(text, weightOf = defaultEventWeight) {
|
|
258
|
+
let total = 0;
|
|
259
|
+
for (const line of chainLinesOf(text)) {
|
|
260
|
+
const parsed = parseLine(line);
|
|
261
|
+
if (parsed === undefined)
|
|
262
|
+
continue;
|
|
263
|
+
const w = weightOf(parsed.record);
|
|
264
|
+
if (Number.isFinite(w) && w > 0)
|
|
265
|
+
total += Math.floor(w);
|
|
266
|
+
}
|
|
267
|
+
return total;
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Verify a chained log. PURE: hand it the lines, get back every defect it can name.
|
|
271
|
+
*
|
|
272
|
+
* A file that has never been chained verifies OK with `preChainPrefix === lines` — records written
|
|
273
|
+
* before chaining existed are legal, and the uncovered prefix is reported as a count rather than
|
|
274
|
+
* flagged (FR-5). Once the chain starts, an unchained record after it is a hole (`BrokenLink`).
|
|
275
|
+
*/
|
|
276
|
+
export function verifyEventChain(lines, opts = {}) {
|
|
277
|
+
const weightOf = typeof opts.eventWeight === 'function' ? opts.eventWeight : defaultEventWeight;
|
|
278
|
+
const defects = [];
|
|
279
|
+
const claims = [];
|
|
280
|
+
// Index of the last non-empty line, so "torn TAIL" can be told from a torn record mid-file.
|
|
281
|
+
let lastNonEmpty = -1;
|
|
282
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
283
|
+
if (stripEol(lines[i] ?? '').trim() !== '')
|
|
284
|
+
lastNonEmpty = i;
|
|
285
|
+
}
|
|
286
|
+
let examined = 0;
|
|
287
|
+
let chained = 0;
|
|
288
|
+
let preChainPrefix = 0;
|
|
289
|
+
let resets = 0;
|
|
290
|
+
let chainStarted = false;
|
|
291
|
+
let segment = 0;
|
|
292
|
+
let prevLine;
|
|
293
|
+
let prevSeq;
|
|
294
|
+
let seen = new Set();
|
|
295
|
+
// CROSS-SEGMENT duplicate CONTENT (Codex re-QE MED): the per-segment `seen` set resets on every
|
|
296
|
+
// re-anchor, so a whole duplicated `seq 1..N` segment produced only the one BrokenLink and the
|
|
297
|
+
// copies hid behind the restart. `(seq, line-hash)` pairs are tracked GLOBALLY: identical content
|
|
298
|
+
// repeating under the same seq is named wherever it lands — while AM-9's healthy-successor case
|
|
299
|
+
// (same seqs, DIFFERENT records) stays cascade-free.
|
|
300
|
+
const seenPairs = new Set();
|
|
301
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
302
|
+
const line = stripEol(lines[i] ?? '');
|
|
303
|
+
if (line.trim() === '')
|
|
304
|
+
continue;
|
|
305
|
+
examined += 1;
|
|
306
|
+
const lineNo = i + 1;
|
|
307
|
+
const parsed = parseLine(line);
|
|
308
|
+
if (parsed === undefined) {
|
|
309
|
+
defects.push({
|
|
310
|
+
kind: 'TornTail',
|
|
311
|
+
line: lineNo,
|
|
312
|
+
detail: i === lastNonEmpty
|
|
313
|
+
? 'the last line is not a readable record — a partial write; the next append starts a marked segment'
|
|
314
|
+
: 'unreadable record mid-file (a torn write that was later appended past) — its content is lost',
|
|
315
|
+
});
|
|
316
|
+
prevLine = line; // its raw bytes are still what the next record hashed
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
if (parsed.seq === undefined || parsed.prevHash === undefined) {
|
|
320
|
+
if (chainStarted) {
|
|
321
|
+
defects.push({
|
|
322
|
+
kind: 'BrokenLink',
|
|
323
|
+
line: lineNo,
|
|
324
|
+
detail: 'record has no chain fields but the chain already started — a hole in the chain',
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
else {
|
|
328
|
+
preChainPrefix += 1;
|
|
329
|
+
}
|
|
330
|
+
prevLine = line;
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
chained += 1;
|
|
334
|
+
const isSegmentStart = parsed.prevHash === EVENT_CHAIN_GENESIS_HASH && parsed.seq === 1;
|
|
335
|
+
if (!chainStarted) {
|
|
336
|
+
chainStarted = true;
|
|
337
|
+
segment = 1;
|
|
338
|
+
seen = new Set();
|
|
339
|
+
prevSeq = undefined;
|
|
340
|
+
if (!isSegmentStart) {
|
|
341
|
+
// The chain's very first link must anchor to genesis; anything else lost its predecessors.
|
|
342
|
+
defects.push({
|
|
343
|
+
kind: 'BrokenLink',
|
|
344
|
+
line: lineNo,
|
|
345
|
+
seq: parsed.seq,
|
|
346
|
+
detail: `the first chained record does not start a segment (seq ${parsed.seq}, prevHash ${parsed.prevHash}) — earlier chained records were lost`,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
else if (parsed.reset) {
|
|
350
|
+
// The chain's first link is itself a recorded discontinuity (the writer met a torn tail
|
|
351
|
+
// before any chained record existed). Still a restart, still counted.
|
|
352
|
+
resets += 1;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
else if (parsed.reset) {
|
|
356
|
+
if (!isSegmentStart) {
|
|
357
|
+
defects.push({
|
|
358
|
+
kind: 'BrokenLink',
|
|
359
|
+
line: lineNo,
|
|
360
|
+
seq: parsed.seq,
|
|
361
|
+
detail: 'record is marked chainReset but does not start a segment (needs seq 1 and the genesis prevHash)',
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
else {
|
|
365
|
+
// AM-4 (Codex QE HIGH-4): a marked restart does NOT absolve an unfinished claim. A rewrite
|
|
366
|
+
// could emit `ledger, two records, RESET, an aggregate of 100` and have the 100 fall outside
|
|
367
|
+
// every claim's segment — laundering events through a legal-looking discontinuity. Two
|
|
368
|
+
// independent lines close it: this one, which names the restart as the cause, and the
|
|
369
|
+
// end-of-file sweep below, which reports any claim left open however the segment ended.
|
|
370
|
+
// Each has its own test; removing either one turns a report RED.
|
|
371
|
+
for (const c of claims) {
|
|
372
|
+
if (c.open && c.segment === segment && c.interruptedAtLine === null)
|
|
373
|
+
c.interruptedAtLine = lineNo;
|
|
374
|
+
}
|
|
375
|
+
resets += 1;
|
|
376
|
+
segment += 1;
|
|
377
|
+
seen = new Set();
|
|
378
|
+
prevSeq = undefined;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
else {
|
|
382
|
+
// A normal link: its prevHash must be the hash of the line above it.
|
|
383
|
+
const expected = prevLine === undefined ? EVENT_CHAIN_GENESIS_HASH : chainHashOf(prevLine);
|
|
384
|
+
const unmarkedRestart = isSegmentStart && parsed.prevHash !== expected;
|
|
385
|
+
if (parsed.prevHash !== expected) {
|
|
386
|
+
defects.push({
|
|
387
|
+
kind: 'BrokenLink',
|
|
388
|
+
line: lineNo,
|
|
389
|
+
seq: parsed.seq,
|
|
390
|
+
detail: unmarkedRestart
|
|
391
|
+
? 'segment restart without a chainReset marker — a break the writer did not record'
|
|
392
|
+
: `prevHash ${parsed.prevHash} does not match the preceding line (${expected})`,
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
if (unmarkedRestart) {
|
|
396
|
+
// RE-ANCHOR. Observed live 2026-07-29: one incident (a single unchained record slipped in
|
|
397
|
+
// from the hook's degraded fallback path) produced FIVE defects — the break itself plus a
|
|
398
|
+
// DuplicateSeq for every healthy record that followed, because the new segment's seq 1,2,3
|
|
399
|
+
// collided with the old segment's. A cascade buries the incident it is reporting. The break
|
|
400
|
+
// is named once, above; from here the records are judged on their own segment.
|
|
401
|
+
//
|
|
402
|
+
// An unmarked restart interrupts open claims exactly as a marked one does. Honest scope of
|
|
403
|
+
// that line: DETECTION already comes from the end-of-file sweep (a claim left open is
|
|
404
|
+
// reported however the segment ended), so this is what makes the report name the CAUSE and
|
|
405
|
+
// its line rather than saying only "the chain ends before it".
|
|
406
|
+
for (const c of claims) {
|
|
407
|
+
if (c.open && c.segment === segment && c.interruptedAtLine === null)
|
|
408
|
+
c.interruptedAtLine = lineNo;
|
|
409
|
+
}
|
|
410
|
+
segment += 1;
|
|
411
|
+
seen = new Set();
|
|
412
|
+
prevSeq = undefined;
|
|
413
|
+
}
|
|
414
|
+
else if (prevSeq !== undefined) {
|
|
415
|
+
// ORDER IS LOAD-BEARING. Equality first: two writers that read the same tail both mint
|
|
416
|
+
// `prevSeq + 1`, and calling that "non-monotonic" would bury the race under the wrong name.
|
|
417
|
+
// Below-previous is the restart shape, which deserves the name that says a step went
|
|
418
|
+
// backwards. A repeat that is neither adjacent nor below can only follow an earlier break.
|
|
419
|
+
if (parsed.seq === prevSeq) {
|
|
420
|
+
defects.push({
|
|
421
|
+
kind: 'DuplicateSeq',
|
|
422
|
+
line: lineNo,
|
|
423
|
+
seq: parsed.seq,
|
|
424
|
+
detail: `seq ${parsed.seq} repeats the previous record — a duplicated record, or two writers racing on the same tail`,
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
else if (parsed.seq < prevSeq) {
|
|
428
|
+
defects.push({
|
|
429
|
+
kind: 'NonMonotonicSeq',
|
|
430
|
+
line: lineNo,
|
|
431
|
+
seq: parsed.seq,
|
|
432
|
+
detail: `seq ${parsed.seq} follows ${prevSeq} without a recorded chainReset`,
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
else if (seen.has(parsed.seq)) {
|
|
436
|
+
defects.push({
|
|
437
|
+
kind: 'DuplicateSeq',
|
|
438
|
+
line: lineNo,
|
|
439
|
+
seq: parsed.seq,
|
|
440
|
+
detail: `seq ${parsed.seq} already appears in this segment`,
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
const contentPair = `${parsed.seq}|${chainHashOf(line)}`;
|
|
446
|
+
if (seenPairs.has(contentPair)) {
|
|
447
|
+
defects.push({
|
|
448
|
+
kind: 'DuplicateSeq',
|
|
449
|
+
line: lineNo,
|
|
450
|
+
seq: parsed.seq,
|
|
451
|
+
detail: 'cross-segment duplicate content — the same seq with an identical record appeared earlier (a re-anchored restart does not absolve copies)',
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
seenPairs.add(contentPair);
|
|
455
|
+
seen.add(parsed.seq);
|
|
456
|
+
prevSeq = parsed.seq;
|
|
457
|
+
prevLine = line;
|
|
458
|
+
// Accounting FIRST, then registration: a ledger never counts itself.
|
|
459
|
+
for (const c of claims) {
|
|
460
|
+
if (c.segment === segment && parsed.seq > c.fromSeq && parsed.seq <= c.throughSeq) {
|
|
461
|
+
const w = weightOf(parsed.record);
|
|
462
|
+
if (Number.isFinite(w) && w > 0)
|
|
463
|
+
c.accounted += Math.floor(w);
|
|
464
|
+
}
|
|
465
|
+
if (c.open && c.segment === segment && parsed.seq >= c.throughSeq)
|
|
466
|
+
c.open = false;
|
|
467
|
+
}
|
|
468
|
+
const ledger = readLedgerClaim(parsed.record, lineNo, segment, parsed.seq);
|
|
469
|
+
if (ledger !== undefined) {
|
|
470
|
+
if ('defect' in ledger)
|
|
471
|
+
defects.push(ledger.defect);
|
|
472
|
+
else
|
|
473
|
+
claims.push(ledger.claim);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
for (const c of claims) {
|
|
477
|
+
if (c.open && c.interruptedAtLine === null)
|
|
478
|
+
c.interruptedAtLine = -1; // ran out of file
|
|
479
|
+
if (c.interruptedAtLine !== null) {
|
|
480
|
+
defects.push({
|
|
481
|
+
kind: 'ClaimInterrupted',
|
|
482
|
+
line: c.line,
|
|
483
|
+
detail: c.interruptedAtLine === -1
|
|
484
|
+
? `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`
|
|
485
|
+
: `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`,
|
|
486
|
+
});
|
|
487
|
+
continue; // an unfinished claim has no arithmetic to check; reporting both would be noise
|
|
488
|
+
}
|
|
489
|
+
// AM-3: the raw equality, no clamps. `droppedEvents` is what the budget deliberately discarded,
|
|
490
|
+
// so a rewrite must account for exactly what it did not drop.
|
|
491
|
+
if (c.droppedEvents < 0 || c.droppedEvents > c.sourceEvents) {
|
|
492
|
+
defects.push({
|
|
493
|
+
kind: 'LedgerImbalance',
|
|
494
|
+
line: c.line,
|
|
495
|
+
detail: `droppedEvents ${c.droppedEvents} is outside [0, sourceEvents=${c.sourceEvents}] — a rewrite cannot discard events it never read`,
|
|
496
|
+
});
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
const total = c.accounted + c.droppedEvents;
|
|
500
|
+
if (total > c.sourceEvents) {
|
|
501
|
+
defects.push({
|
|
502
|
+
kind: 'DoubleCounted',
|
|
503
|
+
line: c.line,
|
|
504
|
+
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)`,
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
else if (total < c.sourceEvents) {
|
|
508
|
+
defects.push({
|
|
509
|
+
kind: 'LedgerImbalance',
|
|
510
|
+
line: c.line,
|
|
511
|
+
detail: `the rewrite accounts for ${c.accounted} event(s) + ${c.droppedEvents} dropped = ${total}, but claimed ${c.sourceEvents} — records it carried are unaccounted for`,
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
defects.sort((a, b) => a.line - b.line || a.kind.localeCompare(b.kind));
|
|
516
|
+
return {
|
|
517
|
+
ok: defects.length === 0,
|
|
518
|
+
lines: examined,
|
|
519
|
+
chained,
|
|
520
|
+
preChainPrefix,
|
|
521
|
+
resets,
|
|
522
|
+
lastSeq: prevSeq ?? null,
|
|
523
|
+
defects,
|
|
524
|
+
scope: EVENT_CHAIN_SCOPE,
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Read a ledger line into a CLAIM, or into a named defect. Nothing in between.
|
|
529
|
+
*
|
|
530
|
+
* AM-5 (Codex QE HIGH-5): the previous version returned `undefined` for a malformed ledger, which
|
|
531
|
+
* meant a hand-edited or truncated ledger DISABLED the accounting check in silence — the strongest
|
|
532
|
+
* check in the module, switched off by damaging the very line that carries it. An invalid ledger in
|
|
533
|
+
* the chained region is now `MalformedLedger`.
|
|
534
|
+
*
|
|
535
|
+
* AM-3 (Codex QE HIGH-3): NO CLAMPS HERE. `Math.max(0, source - dropped)` accepted `dropped > source`
|
|
536
|
+
* and quietly turned an impossible claim into a satisfiable one. The verifier enforces the raw
|
|
537
|
+
* equality; the clamp belongs in the WRITER, which must never emit a negative in the first place.
|
|
538
|
+
*/
|
|
539
|
+
function readLedgerClaim(record, line, segment, seq) {
|
|
540
|
+
if (record['kind'] !== EVENT_CHAIN_LEDGER_KIND)
|
|
541
|
+
return undefined;
|
|
542
|
+
const source = record['sourceEvents'];
|
|
543
|
+
const dropped = record['droppedEvents'];
|
|
544
|
+
const through = record['throughSeq'];
|
|
545
|
+
const bad = (detail) => ({
|
|
546
|
+
defect: { kind: 'MalformedLedger', line, seq, detail },
|
|
547
|
+
});
|
|
548
|
+
// Every numeric read from a log gets a finite/integer check: a `1e400` in a hand-edited file
|
|
549
|
+
// parses to Infinity, and an Infinity ceiling silently disables the check it is supposed to be.
|
|
550
|
+
if (typeof source !== 'number' || !Number.isSafeInteger(source) || source < 0) {
|
|
551
|
+
return bad(`sourceEvents is not a non-negative integer (${describe(source)}) — the accounting claim is unusable`);
|
|
552
|
+
}
|
|
553
|
+
if (typeof through !== 'number' || !Number.isSafeInteger(through) || through < seq) {
|
|
554
|
+
return bad(`throughSeq is not an integer >= this record's own seq (${describe(through)}) — the claim covers no readable range`);
|
|
555
|
+
}
|
|
556
|
+
if (dropped !== undefined && (typeof dropped !== 'number' || !Number.isSafeInteger(dropped))) {
|
|
557
|
+
return bad(`droppedEvents is not an integer (${describe(dropped)})`);
|
|
558
|
+
}
|
|
559
|
+
const droppedEvents = typeof dropped === 'number' ? dropped : 0;
|
|
560
|
+
return {
|
|
561
|
+
claim: {
|
|
562
|
+
line,
|
|
563
|
+
segment,
|
|
564
|
+
fromSeq: seq,
|
|
565
|
+
throughSeq: through,
|
|
566
|
+
sourceEvents: source,
|
|
567
|
+
droppedEvents,
|
|
568
|
+
accounted: 0,
|
|
569
|
+
open: through > seq,
|
|
570
|
+
interruptedAtLine: null,
|
|
571
|
+
},
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
function describe(value) {
|
|
575
|
+
if (typeof value === 'number')
|
|
576
|
+
return Number.isFinite(value) ? String(value) : 'non-finite';
|
|
577
|
+
return value === undefined ? 'absent' : typeof value;
|
|
578
|
+
}
|
|
579
|
+
export function rewriteSnapshot(text) {
|
|
580
|
+
const safe = typeof text === 'string' ? text : '';
|
|
581
|
+
const lines = chainLinesOf(safe);
|
|
582
|
+
return { bytes: safe.length, lines: lines.length, lastLine: lines[lines.length - 1], textHash: chainHashOf(safe) };
|
|
583
|
+
}
|
|
584
|
+
/** True when the live file is byte-for-byte where the snapshot left it. */
|
|
585
|
+
export function rewriteSnapshotUnchanged(a, b) {
|
|
586
|
+
return a.textHash === b.textHash && a.bytes === b.bytes && a.lines === b.lines && a.lastLine === b.lastLine;
|
|
587
|
+
}
|
|
588
|
+
export const DEFAULT_REWRITE_ATTEMPTS = 3;
|
|
589
|
+
/**
|
|
590
|
+
* Run a whole-file rewrite so that a concurrent append can never be silently overwritten.
|
|
591
|
+
*
|
|
592
|
+
* Three guards, in order:
|
|
593
|
+
* 1. an exclusive lock, so two rewriters cannot interleave at all;
|
|
594
|
+
* 2. a re-read of the live file after computing the new text and BEFORE the rename — if it moved,
|
|
595
|
+
* the attempt is ABANDONED and retried against the newer file, so the append is folded in
|
|
596
|
+
* rather than lost;
|
|
597
|
+
* 3. a bounded attempt count — a log under constant append is left alone, never truncated.
|
|
598
|
+
*
|
|
599
|
+
* HONEST RESIDUAL (state it, do not paper over it): the re-read narrows the window to the interval
|
|
600
|
+
* between the final read and the `rename` syscall; it does not close it, because a conditional
|
|
601
|
+
* rename is not an operation the filesystem offers. What it removes is the wide window (the whole
|
|
602
|
+
* parse + compact + write) that made loss likely rather than rare.
|
|
603
|
+
*
|
|
604
|
+
* Pure orchestration — all IO is injected, which is what makes the race testable deterministically.
|
|
605
|
+
*/
|
|
606
|
+
export function guardedRewrite(io, propose, opts = {}) {
|
|
607
|
+
const max = typeof opts.attempts === 'number' && Number.isFinite(opts.attempts) && opts.attempts >= 1
|
|
608
|
+
? Math.floor(opts.attempts)
|
|
609
|
+
: DEFAULT_REWRITE_ATTEMPTS;
|
|
610
|
+
if (!io.acquireLock())
|
|
611
|
+
return { status: 'locked', attempts: 0, defects: [] };
|
|
612
|
+
let attempts = 0;
|
|
613
|
+
try {
|
|
614
|
+
for (let i = 0; i < max; i += 1) {
|
|
615
|
+
attempts += 1;
|
|
616
|
+
const before = io.read();
|
|
617
|
+
if (typeof before !== 'string')
|
|
618
|
+
return { status: 'unreadable', attempts, defects: [] };
|
|
619
|
+
const snapshot = rewriteSnapshot(before);
|
|
620
|
+
const proposal = propose(before);
|
|
621
|
+
if (proposal.refusedDirty === true) {
|
|
622
|
+
return { status: 'refused-dirty', attempts, defects: proposal.defects ?? [] };
|
|
623
|
+
}
|
|
624
|
+
if (typeof proposal.text !== 'string' || proposal.text === '') {
|
|
625
|
+
return { status: 'empty', attempts, defects: [] };
|
|
626
|
+
}
|
|
627
|
+
const after = io.read();
|
|
628
|
+
if (typeof after !== 'string')
|
|
629
|
+
return { status: 'unreadable', attempts, defects: [] };
|
|
630
|
+
if (!rewriteSnapshotUnchanged(snapshot, rewriteSnapshot(after)))
|
|
631
|
+
continue; // someone appended
|
|
632
|
+
io.replace(proposal.text);
|
|
633
|
+
return { status: 'rewritten', attempts, defects: [] };
|
|
634
|
+
}
|
|
635
|
+
return { status: 'raced', attempts, defects: [] };
|
|
636
|
+
}
|
|
637
|
+
finally {
|
|
638
|
+
io.releaseLock();
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
/** Convenience wrapper for callers holding the whole file. */
|
|
642
|
+
export function verifyEventChainText(text, opts = {}) {
|
|
643
|
+
return verifyEventChain(chainLinesOf(typeof text === 'string' ? text : ''), opts);
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* One line an operator can read. Always carries {@link EVENT_CHAIN_SCOPE} so no surface can print a
|
|
647
|
+
* chain verdict without also printing what it does and does not mean.
|
|
648
|
+
*/
|
|
649
|
+
export function renderEventChainVerification(v, label) {
|
|
650
|
+
const head = v.ok ? 'chain OK' : `chain FAILED (${v.defects.length} defect(s))`;
|
|
651
|
+
const parts = [
|
|
652
|
+
`${label}: ${head}`,
|
|
653
|
+
`${v.chained} chained`,
|
|
654
|
+
`${v.preChainPrefix} pre-chain (uncovered)`,
|
|
655
|
+
];
|
|
656
|
+
if (v.resets > 0)
|
|
657
|
+
parts.push(`${v.resets} recorded restart(s)`);
|
|
658
|
+
const kinds = [...new Set(v.defects.map((d) => d.kind))];
|
|
659
|
+
if (kinds.length > 0)
|
|
660
|
+
parts.push(kinds.join('/'));
|
|
661
|
+
return `${parts.join(' · ')} — ${v.scope}`;
|
|
662
|
+
}
|
|
663
|
+
//# sourceMappingURL=event-chain.js.map
|