@compr/opscontext-mcp 2.10.0 → 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/audit.js CHANGED
@@ -46,7 +46,7 @@
46
46
  // Records every state-changing operation. Each line carries the SHA-256 hash
47
47
  // of the previous line's canonical content, so mutation of any historical
48
48
  // record breaks chain verification at that index.
49
- import { existsSync, mkdirSync, readFileSync, appendFileSync, openSync, closeSync, unlinkSync, statSync, writeSync, readSync, fsyncSync, renameSync, linkSync, readdirSync, constants, } from "fs";
49
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync, openSync, closeSync, unlinkSync, statSync, writeSync, readSync, fsyncSync, renameSync, linkSync, readdirSync, ftruncateSync, fstatSync, constants, } from "fs";
50
50
  import { basename, join } from "path";
51
51
  import { homedir } from "os";
52
52
  import { createHash } from "crypto";
@@ -194,6 +194,10 @@ function readLastHash() {
194
194
  * was rendered as the specific, plausible claim "there is no history".
195
195
  * FIX: throw. appendAudit() must surface problems loudly (see [AUDIT-CHAIN]); call sites
196
196
  * that need isolation already use safeAppend(), which logs to stderr and continues.
197
+ * 2026-09-27: a last record cut short (no final newline) is no longer left to block every append
198
+ * for good: it is set aside and noted on the chain first. [LOCK] [TORN-TAIL-IS-KEPT-AND-CHAINED]
199
+ * This throw remains for a complete last line that is not a record, and for a log that holds
200
+ * no complete record at all; neither ever chains onto genesis.
197
201
  */
198
202
  function parseHeadOrThrow(line) {
199
203
  let rec;
@@ -201,9 +205,9 @@ function parseHeadOrThrow(line) {
201
205
  rec = JSON.parse(line);
202
206
  }
203
207
  catch {
204
- throw new Error("Audit log tail is not valid JSON — refusing to append onto an unknown head. " +
205
- "Inspect the last line of ~/.contextengine/audit.log; a partial final record can be " +
206
- "removed by hand, which verifyChain() will then confirm.");
208
+ throw new Error("Audit log tail is not valid JSON, refusing to append onto an unknown head. " +
209
+ "The last line of ~/.contextengine/audit.log is complete but is not a record (a record cut " +
210
+ "short is set aside automatically); inspect it, and 'contextengine audit-verify' says where it is.");
207
211
  }
208
212
  if (typeof rec.hash !== "string" || rec.hash.length !== 64) {
209
213
  throw new Error("Audit log tail has no usable hash — refusing to append onto an unknown head.");
@@ -263,39 +267,239 @@ export function appendAudit(event, payload, actor = "system") {
263
267
  ensureDir();
264
268
  const release = acquireLockSync();
265
269
  try {
266
- const path = auditPath();
267
- // 🔒 LOCKED [AUDIT-HEAD-FROM-DISK] — 2026-08-17
268
- // ⛔ NEVER derive the head hash from an in-process cache again.
269
- // WHY: the previous code trusted `cachedLastHash` whenever `statSync().size` matched
270
- // a locally-tracked `cachedSize` that was ARITHMETIC (`cachedSize += byteLength`),
271
- // not observed. Any divergence between bytes-we-think-we-wrote and bytes-on-disk
272
- // — a partial write, a concurrent writer whose bytes happened to sum the same, an
273
- // externally rotated/truncated log — left us hashing onto a head that is not the
274
- // real tail, forking the chain. It was a correctness guarantee resting on a
275
- // perf cache, which the file's own [audit-001-write-race] LOCK explicitly warns
276
- // against ("the in-process chain cache is a perf optimization, NOT a correctness
277
- // guarantee").
278
- // FIX: with [AUDIT-TAIL-READ-IS-O1] the true head costs ~0 ms, so there is nothing left
279
- // to optimise. Read it from disk under the lock, every time. The cache is gone.
280
- const prevHash = readLastHash();
281
- const ts = new Date().toISOString();
282
- const hash = computeHash(prevHash, ts, event, actor, payload);
283
- const record = {
284
- ts,
285
- event,
286
- actor,
287
- payload,
288
- prev_hash: prevHash,
289
- hash,
290
- };
291
- const line = JSON.stringify(record) + "\n";
292
- appendFileSync(path, line);
293
- return record;
270
+ return appendHoldingLock(event, payload, actor);
294
271
  }
295
272
  finally {
296
273
  release();
297
274
  }
298
275
  }
276
+ /** appendAudit() for a caller that already holds the append lock (the scrub of the live log). */
277
+ function appendHoldingLock(event, payload, actor) {
278
+ const path = auditPath();
279
+ settleTail(path);
280
+ // [LOCK] [A-REFUSED-APPEND-IS-COUNTED-AND-CHAINED]
281
+ const refused = takeRefusals();
282
+ if (refused) {
283
+ try {
284
+ writeRecord(path, "audit.append_failed", refused.summary, "system");
285
+ refused.done();
286
+ }
287
+ catch (e) {
288
+ refused.putBack();
289
+ throw e;
290
+ }
291
+ }
292
+ return writeRecord(path, event, payload, actor);
293
+ }
294
+ /** Under the append lock: a last record cut short is set aside and noted before anything else is
295
+ * written, so the log ends with a newline afterwards. [LOCK] [TORN-TAIL-IS-KEPT-AND-CHAINED] */
296
+ function settleTail(path) {
297
+ const torn = repairTornTail(path);
298
+ if (torn) {
299
+ writeRecord(path, "audit.torn_tail", {
300
+ kept: torn.kept,
301
+ bytes: torn.bytes,
302
+ sha256: torn.sha256,
303
+ note: "the last record was cut short (full disk or crash); its bytes were moved to the kept file and the log continues from the last complete record",
304
+ }, "system");
305
+ }
306
+ }
307
+ function writeRecord(path, event, payload, actor) {
308
+ // 🔒 LOCKED [AUDIT-HEAD-FROM-DISK] — 2026-08-17
309
+ // ⛔ NEVER derive the head hash from an in-process cache again.
310
+ // WHY: the previous code trusted `cachedLastHash` whenever `statSync().size` matched
311
+ // a locally-tracked `cachedSize` that was ARITHMETIC (`cachedSize += byteLength`),
312
+ // not observed. Any divergence between bytes-we-think-we-wrote and bytes-on-disk
313
+ // — a partial write, a concurrent writer whose bytes happened to sum the same, an
314
+ // externally rotated/truncated log — left us hashing onto a head that is not the
315
+ // real tail, forking the chain. It was a correctness guarantee resting on a
316
+ // perf cache, which the file's own [audit-001-write-race] LOCK explicitly warns
317
+ // against ("the in-process chain cache is a perf optimization, NOT a correctness
318
+ // guarantee").
319
+ // FIX: with [AUDIT-TAIL-READ-IS-O1] the true head costs ~0 ms, so there is nothing left
320
+ // to optimise. Read it from disk under the lock, every time. The cache is gone.
321
+ const prevHash = readLastHash();
322
+ const ts = new Date().toISOString();
323
+ const hash = computeHash(prevHash, ts, event, actor, payload);
324
+ const record = {
325
+ ts,
326
+ event,
327
+ actor,
328
+ payload,
329
+ prev_hash: prevHash,
330
+ hash,
331
+ };
332
+ const line = JSON.stringify(record) + "\n";
333
+ appendFileSync(path, line);
334
+ return record;
335
+ }
336
+ /**
337
+ * [LOCKED] [TORN-TAIL-IS-KEPT-AND-CHAINED] - 2026-09-27
338
+ * [NEVER] leave a cut-short final record in place (every later append is refused for good), delete
339
+ * its bytes, or chain the next record onto anything but the last complete record.
340
+ * WHY: a full disk cut one record in half. [UNREADABLE-HEAD-IS-NOT-GENESIS] then refused every
341
+ * later append, correctly never chaining onto genesis, but for good: 194,875 refusals in the
342
+ * replay, 5 of 5 still refused once space was back, each reported only on stderr, while the
343
+ * receiver answered {"ok":true} and `emit-event` printed "Appended". audit-verify said "0
344
+ * record(s) checked" about 3,564 intact ones (E2E_REVIEW_2026-09 B1-1, 3 of 3 runs). A torn
345
+ * write has one signature: the file does not end with a newline. `kill -9` never produced one
346
+ * (10 of 10 kills during 2 MB appends); a full disk did every time.
347
+ * FIX: under the append lock, when the file does not end with a newline: a fragment that is a
348
+ * whole record only lost its newline, which is added back. Anything else is copied to
349
+ * audit.torn-<time>.partial (fsynced, never deleted), the log is cut back to its last complete
350
+ * record, and an `audit.torn_tail` record naming the kept file, its length and SHA-256 is
351
+ * chained onto that record before the caller's. A file holding no complete record at all is
352
+ * left alone and the append throws, as before. A last line that ends with a newline and is
353
+ * not JSON is not a torn write: it still throws. [LOCK] [UNREADABLE-HEAD-IS-NOT-GENESIS]
354
+ */
355
+ function repairTornTail(path) {
356
+ // One open and one fstat on the common path (runs before every append).
357
+ let fd;
358
+ try {
359
+ fd = openSync(path, constants.O_RDWR);
360
+ }
361
+ catch (e) {
362
+ if (e.code === "ENOENT")
363
+ return null;
364
+ throw e;
365
+ }
366
+ try {
367
+ const size = fstatSync(fd).size;
368
+ if (size === 0)
369
+ return null;
370
+ const last = Buffer.alloc(1);
371
+ readSync(fd, last, 0, 1, size - 1);
372
+ if (last[0] === 10)
373
+ return null; // ends with a complete line: nothing was cut short
374
+ // Find the last newline, one window at a time from the end.
375
+ let nl = -1;
376
+ let pos = size;
377
+ const win = Buffer.alloc(TAIL_READ_BYTES);
378
+ while (pos > 0 && nl === -1) {
379
+ const start = Math.max(0, pos - win.length);
380
+ const n = readSync(fd, win, 0, pos - start, start);
381
+ const i = win.subarray(0, n).lastIndexOf(10);
382
+ if (i !== -1)
383
+ nl = start + i;
384
+ pos = start;
385
+ }
386
+ const fragStart = nl + 1;
387
+ const frag = Buffer.alloc(size - fragStart);
388
+ readSync(fd, frag, 0, frag.length, fragStart);
389
+ let whole = false;
390
+ try {
391
+ const r = JSON.parse(frag.toString("utf-8"));
392
+ whole = typeof r?.hash === "string" && r.hash.length === 64;
393
+ }
394
+ catch {
395
+ whole = false;
396
+ }
397
+ if (whole) {
398
+ writeSync(fd, "\n", size); // a complete record that only lost its newline
399
+ return null;
400
+ }
401
+ if (nl === -1)
402
+ return null; // no complete record to continue from: the head read throws
403
+ const kept = `audit.torn-${new Date().toISOString().replace(/[:.]/g, "-")}.partial`;
404
+ try {
405
+ writeFileAndSync(join(auditDir(), kept), frag);
406
+ }
407
+ catch (e) {
408
+ safeUnlink(join(auditDir(), kept)); // still a full disk: no half-written copy per attempt; the log is untouched
409
+ throw e;
410
+ }
411
+ ftruncateSync(fd, fragStart);
412
+ fsyncSync(fd);
413
+ return { kept, bytes: frag.length, sha256: createHash("sha256").update(frag).digest("hex") };
414
+ }
415
+ finally {
416
+ closeSync(fd);
417
+ }
418
+ }
419
+ /**
420
+ * [LOCKED] [A-REFUSED-APPEND-IS-COUNTED-AND-CHAINED] - 2026-09-27
421
+ * [NEVER] let safeAppend() drop an entry with a stderr line as the only trace, or report success
422
+ * for an append that failed.
423
+ * WHY: safeAppend() isolates hot paths from audit failures, and its only surface was stderr. A
424
+ * writer that crashed inside the append lock cost every other writer ~4 entries over 10 s
425
+ * (B1-2, 3 of 3 runs; 448 such lines in the real launchd log before 2.5.8), a torn tail cost
426
+ * all of them (B1-1), and nothing on the chain or in fleet health said so. The receiver and
427
+ * `emit-event` told their senders "written" regardless.
428
+ * FIX: safeAppend() returns whether it wrote. A refusal adds one line to audit-refused.jsonl (best
429
+ * effort: on a full disk nothing can be written anywhere). The next append that succeeds takes
430
+ * the file (rename, under the append lock) and chains one `audit.append_failed` record with
431
+ * the count, the time span, the kinds and the errors, so the gap is on the chain; if that
432
+ * record cannot be written the lines go back. Fleet health reads both. [LOCK] [HEALTH-SEES-THE-CHAIN]
433
+ */
434
+ function refusedPath() {
435
+ return join(auditDir(), "audit-refused.jsonl");
436
+ }
437
+ function recordRefusal(event, error) {
438
+ try {
439
+ appendFileSync(refusedPath(), JSON.stringify({ ts: new Date().toISOString(), pid: process.pid, event, error: error.slice(0, 200) }) + "\n");
440
+ }
441
+ catch {
442
+ /* a full disk: nothing can be written anywhere, stderr already has it */
443
+ }
444
+ }
445
+ /** Pending refusals, taken out of the way; `done` drops them once chained, `putBack` restores them. */
446
+ function takeRefusals() {
447
+ const path = refusedPath();
448
+ if (!existsSync(path))
449
+ return null;
450
+ const taking = `${path}.${process.pid}.taking`;
451
+ let body;
452
+ try {
453
+ renameSync(path, taking);
454
+ body = readFileSync(taking, "utf-8");
455
+ }
456
+ catch {
457
+ return null; // another writer took it, or it vanished: nothing to chain from here
458
+ }
459
+ const events = {};
460
+ const errors = {};
461
+ const pids = new Set();
462
+ let count = 0;
463
+ let first = "";
464
+ let last = "";
465
+ for (const line of body.split("\n")) {
466
+ if (!line)
467
+ continue;
468
+ let r;
469
+ try {
470
+ r = JSON.parse(line);
471
+ }
472
+ catch {
473
+ continue;
474
+ }
475
+ count++;
476
+ if (r.ts && (!first || r.ts < first))
477
+ first = r.ts;
478
+ if (r.ts && r.ts > last)
479
+ last = r.ts;
480
+ if (r.event)
481
+ events[r.event] = (events[r.event] ?? 0) + 1;
482
+ if (r.error)
483
+ errors[r.error] = (errors[r.error] ?? 0) + 1;
484
+ if (typeof r.pid === "number")
485
+ pids.add(r.pid);
486
+ }
487
+ if (count === 0) {
488
+ safeUnlink(taking);
489
+ return null;
490
+ }
491
+ return {
492
+ summary: { count, first, last, events, errors, pids: [...pids] },
493
+ done: () => safeUnlink(taking),
494
+ putBack: () => {
495
+ try {
496
+ appendFileSync(path, body);
497
+ safeUnlink(taking);
498
+ }
499
+ catch { /* the taking file stays for the next run */ }
500
+ },
501
+ };
502
+ }
299
503
  /**
300
504
  * 🔒 LOCKED [ROTATION-MUST-NOT-ORPHAN-THE-CHAIN] — 2026-08-20
301
505
  * ⛔ NEVER rotate by truncating, moving or deleting audit.log. NEVER let a rotated log
@@ -399,32 +603,53 @@ function placeWithoutOverwrite(tmp, target) {
399
603
  safeUnlink(tmp);
400
604
  return true;
401
605
  }
402
- function parseLines(data, label) {
403
- return data
404
- .split("\n")
405
- .filter(Boolean)
406
- .map((line, i) => {
606
+ function parseLines(data, label, unreadable, baseIndex = 0) {
607
+ const out = [];
608
+ const lines = data.split("\n");
609
+ for (let i = 0; i < lines.length; i++) {
610
+ const line = lines[i];
611
+ if (!line)
612
+ continue;
407
613
  try {
408
- return JSON.parse(line);
614
+ out.push(JSON.parse(line));
409
615
  }
410
616
  catch {
411
- throw new Error(`Corrupt audit line ${i + 1} in ${label}: not valid JSON`);
617
+ if (!unreadable)
618
+ throw new Error(`Corrupt audit line ${i + 1} in ${label}: not valid JSON`);
619
+ unreadable.push({ file: label, line: i + 1, beforeIndex: baseIndex + out.length });
412
620
  }
413
- });
621
+ }
622
+ return out;
414
623
  }
415
624
  export function readAuditLog(opts = {}) {
416
- const includeArchives = opts.includeArchives !== false;
625
+ return readHistory(opts.includeArchives !== false);
626
+ }
627
+ /**
628
+ * The history, segments then live log. With `unreadable`, a line that is not JSON is listed there
629
+ * and skipped instead of aborting the read.
630
+ *
631
+ * [LOCKED] [VERIFY-READS-PAST-AN-UNREADABLE-LINE] - 2026-09-27
632
+ * [NEVER] let one unreadable line stop the verifier from checking every other record.
633
+ * WHY: verifyChain() read through readAuditLog(), which throws on the first line that is not JSON,
634
+ * so one cut-short record made audit-verify print "FAILED, 0 record(s) checked" and "treat the
635
+ * affected records as unverified" about 3,564 intact ones (E2E_REVIEW_2026-09 B1-1): the
636
+ * [VERIFY-FORK-IS-NOT-TAMPER] failure again, a verdict on everything from one bad line.
637
+ * FIX: the verifier reads tolerantly: an unreadable line is reported with its file and line number,
638
+ * makes the report fail, and every other record is still checked. Every other reader keeps the
639
+ * strict read, which throws.
640
+ */
641
+ function readHistory(includeArchives, unreadable) {
417
642
  const path = auditPath();
418
- const live = existsSync(path) ? parseLines(readFileSync(path, "utf-8"), "audit.log") : [];
643
+ const liveText = existsSync(path) ? readFileSync(path, "utf-8") : "";
419
644
  if (!includeArchives)
420
- return live;
645
+ return parseLines(liveText, "audit.log", unreadable);
421
646
  const segments = listSegments();
422
647
  if (segments.length === 0)
423
- return live;
648
+ return parseLines(liveText, "audit.log", unreadable);
424
649
  const history = [];
425
650
  let lastSegmentHashes = new Set();
426
651
  for (const f of segments) {
427
- const recs = parseLines(readFileSync(join(archiveDir(), f), "utf-8"), f);
652
+ const recs = parseLines(readFileSync(join(archiveDir(), f), "utf-8"), f, unreadable, history.length);
428
653
  // 🔒 LOCKED [NO-SPREAD-OVER-A-SEGMENT] — 2026-08-20
429
654
  // ⛔ NEVER use push(...records) on a segment. Found on the first real rotation:
430
655
  // a 494,152-record segment threw "Maximum call stack size exceeded" because the
@@ -434,6 +659,7 @@ export function readAuditLog(opts = {}) {
434
659
  history.push(r);
435
660
  lastSegmentHashes = new Set(recs.map((r) => r.hash));
436
661
  }
662
+ const live = parseLines(liveText, "audit.log", unreadable, history.length);
437
663
  // Seam de-dup — see [ROTATE-ARCHIVE-BEFORE-TRUNCATE]. A crash after the segment was
438
664
  // renamed but before the live log was truncated leaves the archived prefix present in
439
665
  // both files. Drop only the LEADING run of live records already in the last segment;
@@ -441,6 +667,12 @@ export function readAuditLog(opts = {}) {
441
667
  let start = 0;
442
668
  while (start < live.length && lastSegmentHashes.has(live[start].hash))
443
669
  start++;
670
+ if (unreadable && start > 0) {
671
+ const base = history.length;
672
+ for (const u of unreadable)
673
+ if (u.file === "audit.log")
674
+ u.beforeIndex = Math.max(base, u.beforeIndex - start);
675
+ }
444
676
  // [LOCK] [NO-SPREAD-OVER-A-SEGMENT] — same reason.
445
677
  for (let i = start; i < live.length; i++)
446
678
  history.push(live[i]);
@@ -522,6 +754,7 @@ export function rotateAuditLog(opts = {}) {
522
754
  };
523
755
  }
524
756
  try {
757
+ finishInterruptedMoves(); // [LOCK] [AN-INTERRUPTED-MOVE-IS-FINISHED]
525
758
  return rotateHoldingLock(opts);
526
759
  }
527
760
  finally {
@@ -575,8 +808,20 @@ function rotateHoldingLock(opts) {
575
808
  mkdirSync(adir, { recursive: true });
576
809
  // Snapshot outside the lock: parsing 500k records is far too slow to hold the append
577
810
  // lock for, and acquireLockSync() force-breaks locks older than STALE_LOCK_MS.
578
- const snapshotSize = statSync(path).size;
579
- const live = parseLines(readFileSync(path, "utf-8"), "audit.log");
811
+ //
812
+ // [LOCKED] [ROTATION-SNAPSHOT-IS-THE-BYTES-READ] - 2026-09-27
813
+ // [NEVER] take the snapshot size from statSync() and the records from a separate read.
814
+ // WHY: Node 20's readFileSync(path, "utf-8") reads to the end of the file, past the size a
815
+ // statSync() just before it returned, whenever a writer appends during the read (10 of 10
816
+ // reads in a replay). The records appended in between were then parsed into the remainder
817
+ // AND copied again as raw bytes from the old size: written twice. It happened for real:
818
+ // 20 learning.import records of 2026-09-25 19:45:46 sit twice in audit-0062.jsonl, and the
819
+ // verifier called it a concurrent-append fork. Replayed 6 of 6 (E2E_REVIEW_2026-09 B2-1).
820
+ // FIX: one read, cut at its last newline; that byte length IS the snapshot, so the raw copy
821
+ // below starts exactly where the parsed records end, whatever the read returned.
822
+ const snapshotBuf = readFileSync(path);
823
+ const snapshotSize = snapshotBuf.lastIndexOf(10) + 1;
824
+ const live = parseLines(snapshotBuf.subarray(0, snapshotSize).toString("utf-8"), "audit.log");
580
825
  // [LOCK] [ROTATION-HOLDS-THE-LOCK-BEFORE-IT-PLANS]: archiveCount is a count from the plan's
581
826
  // read. Appends at the tail since then are fine; a different head means someone else cut or
582
827
  // rewrote the log, and slicing it with an old count archives the wrong records.
@@ -591,9 +836,20 @@ function rotateHoldingLock(opts) {
591
836
  const segName = plan.segmentFile;
592
837
  const segTmp = join(adir, `.${segName}.tmp`);
593
838
  const segBody = archived.map((r) => JSON.stringify(r)).join("\n") + "\n";
839
+ const rotateRecord = {
840
+ segment: segName,
841
+ archived_records: archived.length,
842
+ first_hash: archived[0].hash,
843
+ last_hash: archived[archived.length - 1].hash,
844
+ cutoff: plan.cutoff,
845
+ };
846
+ // [LOCK] [AN-INTERRUPTED-MOVE-IS-FINISHED]: from here to the audit.rotate record, a crash leaves
847
+ // this note, and the next rotation, restore or scrub finishes the job from it.
848
+ writeIntent(ROTATE_INTENT, rotateRecord);
594
849
  writeFileAndSync(segTmp, segBody);
595
850
  // [LOCK] [SEGMENT-IS-NEVER-OVERWRITTEN]
596
851
  if (!placeWithoutOverwrite(segTmp, join(adir, segName))) {
852
+ clearIntent(ROTATE_INTENT);
597
853
  return { ...empty, refusedReason: `segment ${segName} already exists; refusing to overwrite archived history` };
598
854
  }
599
855
  // [ROTATE-ARCHIVE-BEFORE-TRUNCATE]: the segment is durable from here on. Only now may
@@ -610,6 +866,7 @@ function rotateHoldingLock(opts) {
610
866
  // Our segment then only duplicates records that other rotation archived, so it goes.
611
867
  if (currentSize < snapshotSize || readFirstRecordHash(path) !== plan.firstLiveHash) {
612
868
  safeUnlink(join(adir, segName));
869
+ clearIntent(ROTATE_INTENT);
613
870
  return {
614
871
  ...empty,
615
872
  refusedReason: "the live log was cut by another writer during this rotation; our segment was removed and nothing else was written",
@@ -638,13 +895,8 @@ function rotateHoldingLock(opts) {
638
895
  }
639
896
  // Self-documenting evidence: the rotation itself is an audited event, chained onto the
640
897
  // new head like any other record.
641
- appendAudit("audit.rotate", {
642
- segment: segName,
643
- archived_records: archived.length,
644
- first_hash: archived[0].hash,
645
- last_hash: archived[archived.length - 1].hash,
646
- cutoff: plan.cutoff,
647
- }, "system");
898
+ appendAudit("audit.rotate", rotateRecord, "system");
899
+ clearIntent(ROTATE_INTENT);
648
900
  return {
649
901
  ...plan,
650
902
  rotated: true,
@@ -716,6 +968,172 @@ function acquireRotateLock() {
716
968
  // Another process broke the same stale lock and won the create.
717
969
  return { heldMs: 0 };
718
970
  }
971
+ /**
972
+ * [LOCKED] [AN-INTERRUPTED-MOVE-IS-FINISHED] - 2026-09-27
973
+ * [NEVER] let a rotation or a restore that stopped halfway be archived again, stay unrecorded on the
974
+ * chain, or leave a temp file that outlives a scrub.
975
+ * WHY: replayed in real processes killed at each write (E2E_REVIEW_2026-09 B2-2, B2-3, B3-3):
976
+ * - segment placed, live log not yet cut (9 of 9 kills, and a full disk 3 of 3): the seam de-dup
977
+ * hid the overlap until the NEXT rotation archived the same 70,000 records again, and the
978
+ * verifier then said "190,011 records verified" for 120,011. [ROTATE-ARCHIVE-BEFORE-TRUNCATE]
979
+ * promised this state "loses nothing and is de-duplicated": true only until the next rotation;
980
+ * - live log cut, or a restore placed, and the process gone before its record (9 of 9): history
981
+ * moved or came back with no audit.rotate / audit.restore saying who, when and why, and a
982
+ * retried restore is refused because its records are already there;
983
+ * - segment linked, its temp name not yet removed (3 of 3): the hidden second name kept 700
984
+ * planted keys through a scrub that reported success.
985
+ * FIX: before a rotation writes its segment, and before a restore places its block, a small note
986
+ * (.rotate-intent.json, .restore-intent.json in audit-archive/) names the move. Every holder of
987
+ * the rotate lock (rotation, auto-rotation even below its trigger, restore, scrub) first runs
988
+ * finishInterruptedMoves(): temp files are removed (every writer of them holds this lock, and
989
+ * each is a copy of records present elsewhere); for a noted rotation whose segment exists, the
990
+ * leading live records already in that segment are dropped under the append lock and the
991
+ * missing audit.rotate record is chained, marked completed_after_interruption; for a noted
992
+ * restore whose segment exists, its audit.restore record, reason included, is chained the same
993
+ * way. A record already in the live log is never written twice.
994
+ */
995
+ const ROTATE_INTENT = ".rotate-intent.json";
996
+ const RESTORE_INTENT = ".restore-intent.json";
997
+ const LIVE_TEMPS = [".audit.log.tmp", ".audit.log.scrub.tmp"];
998
+ function writeIntent(name, data) {
999
+ mkdirSync(archiveDir(), { recursive: true });
1000
+ writeFileAndSync(join(archiveDir(), name), JSON.stringify(data));
1001
+ }
1002
+ /** The note, or null. A note that exists but cannot be read (cut short by the same crash) is
1003
+ * removed and reported: it names nothing we can finish, and left in place it would keep every
1004
+ * hourly auto-rotation taking the lock for nothing. */
1005
+ function readIntent(name) {
1006
+ const path = join(archiveDir(), name);
1007
+ if (!existsSync(path))
1008
+ return null;
1009
+ try {
1010
+ const v = JSON.parse(readFileSync(path, "utf-8"));
1011
+ if (v && typeof v.segment === "string")
1012
+ return v;
1013
+ }
1014
+ catch { /* unreadable: dropped below */ }
1015
+ console.error(`[ContextEngine] audit: ${name} could not be read and was removed; audit-verify reports anything it left unfinished`);
1016
+ safeUnlink(path);
1017
+ return null;
1018
+ }
1019
+ function clearIntent(name) {
1020
+ safeUnlink(join(archiveDir(), name));
1021
+ }
1022
+ function leftoverTemps() {
1023
+ const out = [];
1024
+ if (existsSync(archiveDir())) {
1025
+ for (const f of readdirSync(archiveDir()))
1026
+ if (f.startsWith(".") && f.endsWith(".tmp"))
1027
+ out.push(join(archiveDir(), f));
1028
+ }
1029
+ for (const f of LIVE_TEMPS)
1030
+ if (existsSync(join(auditDir(), f)))
1031
+ out.push(join(auditDir(), f));
1032
+ return out;
1033
+ }
1034
+ /** Cheap: is there anything for finishInterruptedMoves() to do? */
1035
+ export function interruptedMovePending() {
1036
+ return existsSync(join(archiveDir(), ROTATE_INTENT)) || existsSync(join(archiveDir(), RESTORE_INTENT)) || leftoverTemps().length > 0;
1037
+ }
1038
+ function describeFinish(f) {
1039
+ const parts = [];
1040
+ if (f.rotation)
1041
+ parts.push(`rotation to ${f.rotation.segment} finished (${f.rotation.duplicatesDropped} record(s) already archived dropped from the live log, record ${f.rotation.recorded ? "chained" : "already there"})`);
1042
+ if (f.restore)
1043
+ parts.push(`restore of ${f.restore.segment} recorded ${f.restore.recorded ? "late" : "already"}`);
1044
+ if (f.tempsRemoved.length > 0)
1045
+ parts.push(`${f.tempsRemoved.length} leftover temp file(s) removed`);
1046
+ return parts.length > 0 ? parts.join("; ") : "nothing to finish";
1047
+ }
1048
+ /** Does the live log text hold a record of this kind naming this segment? */
1049
+ function liveNames(text, event, segment) {
1050
+ const hint = `"event":"${event}"`;
1051
+ for (const line of text.split("\n")) {
1052
+ if (!line.includes(hint) || !line.includes(segment))
1053
+ continue;
1054
+ try {
1055
+ const r = JSON.parse(line);
1056
+ if (r.event === event && r.payload.segment === segment)
1057
+ return true;
1058
+ }
1059
+ catch { /* not a record */ }
1060
+ }
1061
+ return false;
1062
+ }
1063
+ /** Run with the rotate lock held. [LOCK] [AN-INTERRUPTED-MOVE-IS-FINISHED] */
1064
+ export function finishInterruptedMoves() {
1065
+ const report = { tempsRemoved: [], rotation: null, restore: null };
1066
+ for (const t of leftoverTemps()) {
1067
+ safeUnlink(t);
1068
+ if (!existsSync(t))
1069
+ report.tempsRemoved.push(basename(t));
1070
+ }
1071
+ const ri = readIntent(ROTATE_INTENT);
1072
+ if (ri && typeof ri.segment === "string") {
1073
+ const seg = join(archiveDir(), ri.segment);
1074
+ if (existsSync(seg)) {
1075
+ const segHashes = new Set(parseLines(readFileSync(seg, "utf-8"), ri.segment).map((r) => r.hash));
1076
+ const path = auditPath();
1077
+ const release = acquireLockSync();
1078
+ try {
1079
+ settleTail(path);
1080
+ const text = existsSync(path) ? readFileSync(path, "utf-8") : "";
1081
+ const lines = text.split("\n");
1082
+ let dropped = 0;
1083
+ while (dropped < lines.length && lines[dropped]) {
1084
+ let h;
1085
+ try {
1086
+ h = JSON.parse(lines[dropped]).hash;
1087
+ }
1088
+ catch {
1089
+ break;
1090
+ }
1091
+ if (!segHashes.has(h))
1092
+ break;
1093
+ dropped++;
1094
+ }
1095
+ if (dropped > 0) {
1096
+ const tmp = join(auditDir(), ".audit.log.tmp");
1097
+ writeFileAndSync(tmp, lines.slice(dropped).join("\n"));
1098
+ renameSync(tmp, path);
1099
+ }
1100
+ const recorded = !liveNames(text, "audit.rotate", ri.segment);
1101
+ if (recorded) {
1102
+ writeRecord(path, "audit.rotate", { ...ri, completed_after_interruption: true, duplicates_dropped: dropped }, "system");
1103
+ }
1104
+ report.rotation = { segment: ri.segment, duplicatesDropped: dropped, recorded };
1105
+ }
1106
+ finally {
1107
+ release();
1108
+ }
1109
+ }
1110
+ clearIntent(ROTATE_INTENT);
1111
+ }
1112
+ const si = readIntent(RESTORE_INTENT);
1113
+ if (si && typeof si.segment === "string") {
1114
+ if (existsSync(join(archiveDir(), si.segment))) {
1115
+ const path = auditPath();
1116
+ const release = acquireLockSync();
1117
+ try {
1118
+ settleTail(path);
1119
+ const text = existsSync(path) ? readFileSync(path, "utf-8") : "";
1120
+ const recorded = !liveNames(text, "audit.restore", si.segment);
1121
+ const { actor, ...payload } = si;
1122
+ if (recorded)
1123
+ writeRecord(path, "audit.restore", { ...payload, completed_after_interruption: true }, typeof actor === "string" ? actor : "system");
1124
+ report.restore = { segment: si.segment, recorded };
1125
+ }
1126
+ finally {
1127
+ release();
1128
+ }
1129
+ }
1130
+ clearIntent(RESTORE_INTENT);
1131
+ }
1132
+ if (report.tempsRemoved.length > 0 || report.rotation || report.restore) {
1133
+ console.error(`[ContextEngine] audit: finished an interrupted move: ${describeFinish(report)}`);
1134
+ }
1135
+ return report;
1136
+ }
719
1137
  /** Count newline-terminated lines without parsing. The live log is small by construction. */
720
1138
  export function countLiveRecords() {
721
1139
  const path = auditPath();
@@ -736,6 +1154,23 @@ export function autoRotateAuditLog(opts = {}) {
736
1154
  }
737
1155
  const liveRecords = countLiveRecords();
738
1156
  if (liveRecords <= trigger) {
1157
+ // A move interrupted after the live log was cut leaves the log below the trigger: finish it now,
1158
+ // not when the log next grows past it. [LOCK] [AN-INTERRUPTED-MOVE-IS-FINISHED]
1159
+ if (interruptedMovePending()) {
1160
+ const lock = acquireRotateLock();
1161
+ if ("heldMs" in lock)
1162
+ return { action: "in_progress", liveRecords, detail: "another rotation holds the lock" };
1163
+ try {
1164
+ const f = finishInterruptedMoves();
1165
+ return { action: "finished", liveRecords, detail: describeFinish(f) };
1166
+ }
1167
+ catch (e) {
1168
+ return { action: "error", liveRecords, detail: e.message };
1169
+ }
1170
+ finally {
1171
+ lock.release();
1172
+ }
1173
+ }
739
1174
  return { action: "below_trigger", liveRecords, detail: `${liveRecords} live record(s), trigger is ${trigger}` };
740
1175
  }
741
1176
  // One runner at a time: rotateAuditLog() takes the rotate lock itself, so this path and the
@@ -760,9 +1195,13 @@ export function autoRotateAuditLog(opts = {}) {
760
1195
  }
761
1196
  }
762
1197
  function writeFileAndSync(target, body) {
1198
+ const buf = typeof body === "string" ? Buffer.from(body, "utf-8") : body;
763
1199
  const fd = openSync(target, "w");
764
1200
  try {
765
- writeSync(fd, body);
1201
+ // writeSync may write fewer bytes than asked (a disk filling up): loop until all are down.
1202
+ let off = 0;
1203
+ while (off < buf.length)
1204
+ off += writeSync(fd, buf, off, buf.length - off);
766
1205
  fsyncSync(fd);
767
1206
  }
768
1207
  finally {
@@ -791,11 +1230,17 @@ function writeFileAndSync(target, body) {
791
1230
  * `ok` is true when there are no tampered and no orphan records. Forks are surfaced
792
1231
  * with counts and indices so the report stays honest in both directions — it must
793
1232
  * never claim a forked log is pristine either.
1233
+ * 2026-09-27: a fourth class. A record whose hash was already seen is a DUPLICATE (a second copy
1234
+ * of the same record), counted once and skipped for linkage, so the record after a copied
1235
+ * block links to the original. Before, the first copy read as a "fork" and the total counted
1236
+ * every copy: 190,011 records "verified" for 120,011 real ones after an interrupted rotation
1237
+ * (E2E_REVIEW_2026-09 B2-1, B2-2). A copy's content is still checked against its own hash.
794
1238
  */
795
1239
  export function verifyChain() {
796
1240
  let records;
1241
+ const unreadable = [];
797
1242
  try {
798
- records = readAuditLog();
1243
+ records = readHistory(true, unreadable); // [LOCK] [VERIFY-READS-PAST-AN-UNREADABLE-LINE]
799
1244
  }
800
1245
  catch (e) {
801
1246
  return {
@@ -808,6 +1253,7 @@ export function verifyChain() {
808
1253
  const tampered = [];
809
1254
  const orphans = [];
810
1255
  const forks = [];
1256
+ const duplicates = [];
811
1257
  // Every hash observed so far, so a fork (parent = a known earlier head) can be told
812
1258
  // apart from an orphan (parent never existed in this log).
813
1259
  const seen = new Set([GENESIS_HASH]);
@@ -820,6 +1266,12 @@ export function verifyChain() {
820
1266
  const expected = computeHash(r.prev_hash, r.ts, r.event, r.actor, r.payload);
821
1267
  if (r.hash !== expected)
822
1268
  tampered.push(i);
1269
+ // 1b. A second copy of a record already in the history: counted once, never relinked, so
1270
+ // the record after a copied block still links to the original. [LOCK] [VERIFY-FORK-IS-NOT-TAMPER]
1271
+ if (seen.has(r.hash)) {
1272
+ duplicates.push(i);
1273
+ continue;
1274
+ }
823
1275
  // 2. Linkage — fork vs orphan.
824
1276
  if (r.prev_hash !== prev) {
825
1277
  if (seen.has(r.prev_hash))
@@ -861,12 +1313,15 @@ export function verifyChain() {
861
1313
  }
862
1314
  tampered.length = 0;
863
1315
  tampered.push(...stillTampered);
864
- const ok = tampered.length === 0 && orphans.length === 0;
865
- const firstProblem = tampered.length > 0 ? tampered[0] : orphans.length > 0 ? orphans[0] : null;
1316
+ const ok = tampered.length === 0 && orphans.length === 0 && unreadable.length === 0;
1317
+ const firstProblem = tampered.length > 0 ? tampered[0] : unreadable.length > 0 ? unreadable[0].beforeIndex : orphans.length > 0 ? orphans[0] : null;
866
1318
  let reason = null;
867
1319
  if (tampered.length > 0) {
868
1320
  reason = `${tampered.length} record(s) with altered content — first at index ${tampered[0]}`;
869
1321
  }
1322
+ else if (unreadable.length > 0) {
1323
+ reason = `${unreadable.length} line(s) that are not records, first at ${unreadable[0].file} line ${unreadable[0].line}; every other record was checked, and the record after such a line cannot be linked`;
1324
+ }
870
1325
  else if (orphans.length > 0) {
871
1326
  reason = `${orphans.length} record(s) whose parent is absent from the log (deleted or truncated history) — first at index ${orphans[0]}`;
872
1327
  }
@@ -878,9 +1333,108 @@ export function verifyChain() {
878
1333
  tamperedIndices: tampered,
879
1334
  orphanIndices: orphans,
880
1335
  forkIndices: forks,
1336
+ duplicateIndices: duplicates,
1337
+ unreadable,
881
1338
  redactedIndices: redacted,
882
1339
  };
883
1340
  }
1341
+ export function verifyStatePath() {
1342
+ return join(auditDir(), "audit-verify.json");
1343
+ }
1344
+ /** Keep the result of a full check. Best effort: a check that cannot record still printed its verdict. */
1345
+ export function recordVerifyState(report, ms, by) {
1346
+ const dups = (report.duplicateIndices ?? []).length;
1347
+ const state = {
1348
+ checkedAt: new Date().toISOString(),
1349
+ ms,
1350
+ by,
1351
+ ok: report.ok,
1352
+ total: report.total,
1353
+ unique: report.total - dups,
1354
+ altered: (report.tamperedIndices ?? []).length,
1355
+ orphans: (report.orphanIndices ?? []).length,
1356
+ unreadable: (report.unreadable ?? []).length,
1357
+ duplicates: dups,
1358
+ forks: (report.forkIndices ?? []).length,
1359
+ redacted: (report.redactedIndices ?? []).length,
1360
+ reason: report.breakReason,
1361
+ };
1362
+ try {
1363
+ ensureDir();
1364
+ const tmp = `${verifyStatePath()}.tmp-${process.pid}`;
1365
+ writeFileSync(tmp, JSON.stringify(state, null, 2) + "\n");
1366
+ renameSync(tmp, verifyStatePath());
1367
+ }
1368
+ catch {
1369
+ /* the verdict was printed; health will say the chain was not checked recently */
1370
+ }
1371
+ return state;
1372
+ }
1373
+ export function readVerifyState() {
1374
+ try {
1375
+ const s = JSON.parse(readFileSync(verifyStatePath(), "utf-8"));
1376
+ return typeof s.checkedAt === "string" && typeof s.ok === "boolean" ? s : null;
1377
+ }
1378
+ catch {
1379
+ return null;
1380
+ }
1381
+ }
1382
+ /** Refusals written by safeAppend() and not chained yet: count, and the newest one. */
1383
+ export function pendingRefusals() {
1384
+ let body = "";
1385
+ try {
1386
+ body = readFileSync(refusedPath(), "utf-8");
1387
+ }
1388
+ catch {
1389
+ return { count: 0, last: null, error: null };
1390
+ }
1391
+ let count = 0;
1392
+ let last = null;
1393
+ let error = null;
1394
+ for (const line of body.split("\n")) {
1395
+ if (!line)
1396
+ continue;
1397
+ try {
1398
+ const r = JSON.parse(line);
1399
+ count++;
1400
+ if (r.ts && (!last || r.ts > last)) {
1401
+ last = r.ts;
1402
+ error = r.error ?? null;
1403
+ }
1404
+ }
1405
+ catch { /* a line being written */ }
1406
+ }
1407
+ return { count, last, error };
1408
+ }
1409
+ /** One scheduled full check at a time across processes: O_EXCL, stale after two hours. */
1410
+ export function acquireVerifyLock() {
1411
+ const lock = join(auditDir(), "audit-verify.lock");
1412
+ ensureDir();
1413
+ for (let attempt = 0; attempt < 2; attempt++) {
1414
+ try {
1415
+ const fd = openSync(lock, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600);
1416
+ try {
1417
+ writeSync(fd, `${process.pid}\n`);
1418
+ }
1419
+ catch { /* courtesy */ }
1420
+ closeSync(fd);
1421
+ return () => safeUnlink(lock);
1422
+ }
1423
+ catch (e) {
1424
+ if (e.code !== "EEXIST")
1425
+ return null;
1426
+ try {
1427
+ if (Date.now() - statSync(lock).mtimeMs < 2 * 3_600_000)
1428
+ return null;
1429
+ }
1430
+ catch {
1431
+ continue;
1432
+ }
1433
+ safeUnlink(lock);
1434
+ }
1435
+ }
1436
+ return null;
1437
+ }
884
1438
  /**
885
1439
  * Acknowledge that records were deliberately redacted (a secret removed from their content).
886
1440
  *
@@ -1028,11 +1582,26 @@ export function restoreSegment(file, opts = {}) {
1028
1582
  return refuse(`the verifier does not report the record after ${prev.name} as an orphan; nothing to restore`, base);
1029
1583
  }
1030
1584
  const adir = archiveDir();
1585
+ const restorePayload = {
1586
+ segment: segName,
1587
+ after: prev.name,
1588
+ records: block.length,
1589
+ first_hash: block[0].hash,
1590
+ last_hash: block[block.length - 1].hash,
1591
+ first_ts: block[0].ts,
1592
+ last_ts: block[block.length - 1].ts,
1593
+ source: basename(file),
1594
+ reason: opts.reason.trim(),
1595
+ };
1596
+ // [LOCK] [AN-INTERRUPTED-MOVE-IS-FINISHED]: a crash from here to the record leaves this note.
1597
+ writeIntent(RESTORE_INTENT, { ...restorePayload, actor: opts.actor ?? "system" });
1031
1598
  const tmp = join(adir, `.${segName}.tmp`);
1032
1599
  writeFileAndSync(tmp, block.map((x) => JSON.stringify(x)).join("\n") + "\n");
1033
1600
  const target = join(adir, segName);
1034
- if (!placeWithoutOverwrite(tmp, target))
1601
+ if (!placeWithoutOverwrite(tmp, target)) {
1602
+ clearIntent(RESTORE_INTENT);
1035
1603
  return refuse(`segment ${segName} already exists; refusing to overwrite it`, base);
1604
+ }
1036
1605
  const afterReport = verifyChain();
1037
1606
  // `>=` on the total: live appends keep landing during two full verifies (seconds on a real
1038
1607
  // log), and they only ever add records.
@@ -1041,19 +1610,11 @@ export function restoreSegment(file, opts = {}) {
1041
1610
  afterReport.total >= beforeReport.total + block.length;
1042
1611
  if (!improved) {
1043
1612
  safeUnlink(target);
1613
+ clearIntent(RESTORE_INTENT);
1044
1614
  return refuse("the chain did not come out exactly one orphan better; the restored segment was removed again", base);
1045
1615
  }
1046
- const record = appendAudit("audit.restore", {
1047
- segment: segName,
1048
- after: prev.name,
1049
- records: block.length,
1050
- first_hash: block[0].hash,
1051
- last_hash: block[block.length - 1].hash,
1052
- first_ts: block[0].ts,
1053
- last_ts: block[block.length - 1].ts,
1054
- source: basename(file),
1055
- reason: opts.reason.trim(),
1056
- }, opts.actor ?? "system");
1616
+ const record = appendAudit("audit.restore", restorePayload, opts.actor ?? "system");
1617
+ clearIntent(RESTORE_INTENT);
1057
1618
  return { ...plan, restored: true, record };
1058
1619
  };
1059
1620
  if (!opts.apply)
@@ -1063,6 +1624,7 @@ export function restoreSegment(file, opts = {}) {
1063
1624
  if ("heldMs" in lock)
1064
1625
  return refuse("a rotation is in progress; try again in a minute", base);
1065
1626
  try {
1627
+ finishInterruptedMoves(); // [LOCK] [AN-INTERRUPTED-MOVE-IS-FINISHED]
1066
1628
  return planAndMaybeWrite();
1067
1629
  }
1068
1630
  finally {
@@ -1127,6 +1689,8 @@ function scrubText(text, redact) {
1127
1689
  * the live log under the append lock (appends wait, none is lost), then one audit.redact
1128
1690
  * record per 100 rewrites names each original hash and its new content hash
1129
1691
  * ([REDACTION-IS-A-CHAINED-RECORD]). Running it again changes nothing.
1692
+ * 2026-09-27 (Yan's GO): the order is now acknowledgement first, rewrite second, for every file.
1693
+ * [LOCK] [SCRUB-ACKNOWLEDGES-BEFORE-IT-REWRITES]
1130
1694
  */
1131
1695
  export function scrubAuditLog(opts) {
1132
1696
  const report = { applied: false, refusedReason: null, files: [], redactedRecords: 0, counts: {}, acknowledgements: [] };
@@ -1138,13 +1702,23 @@ export function scrubAuditLog(opts) {
1138
1702
  for (const [k, n] of Object.entries(s.counts))
1139
1703
  report.counts[k] = (report.counts[k] ?? 0) + n;
1140
1704
  };
1141
- // Acknowledge each file right after it is rewritten, so a scrub that stops halfway leaves no
1142
- // rewritten record unacknowledged.
1143
- const acknowledge = (entries) => {
1705
+ // [LOCKED] [SCRUB-ACKNOWLEDGES-BEFORE-IT-REWRITES] - 2026-09-27
1706
+ // [NEVER] rewrite a file before the acknowledgements for its rewrites are on the chain.
1707
+ // WHY: the scrub renamed each rewritten segment into place, then acknowledged it. Killed between the
1708
+ // two (3 of 3 runs, E2E_REVIEW_2026-09 B3-1), 100 records read as "altered content: this is
1709
+ // tampering", and a rerun could not repair it: it found nothing left to change in them. The
1710
+ // product's own act was reported as an attack, recoverable only by listing 100 indices by hand.
1711
+ // FIX: acknowledge first, rewrite second. A crash in between leaves an acknowledgement for a rewrite
1712
+ // that did not happen: it binds content that is not there, so it is simply unused, and a rerun
1713
+ // rewrites and acknowledges again. The live log is acknowledged and rewritten under one hold of
1714
+ // the append lock: its acknowledgements are appended, then the rewritten records plus exactly
1715
+ // the bytes appended since go in place. [LOCK] [SCRUB-IS-ACKNOWLEDGED-REDACTION]
1716
+ const acknowledge = (entries, write) => {
1144
1717
  for (let i = 0; i < entries.length; i += SCRUB_ACK_BATCH) {
1145
- report.acknowledgements.push(appendAudit("audit.redact", { reason: opts.reason.trim(), redacted: entries.slice(i, i + SCRUB_ACK_BATCH) }, opts.actor ?? "system"));
1718
+ report.acknowledgements.push(write({ reason: opts.reason.trim(), redacted: entries.slice(i, i + SCRUB_ACK_BATCH) }));
1146
1719
  }
1147
1720
  };
1721
+ const actor = opts.actor ?? "system";
1148
1722
  const run = () => {
1149
1723
  const adir = archiveDir();
1150
1724
  for (const name of listSegments()) {
@@ -1154,8 +1728,8 @@ export function scrubAuditLog(opts) {
1154
1728
  if (opts.apply && s.redacted.length > 0) {
1155
1729
  const tmp = join(adir, `.${name}.scrub.tmp`);
1156
1730
  writeFileAndSync(tmp, s.body);
1731
+ acknowledge(s.redacted, (payload) => appendAudit("audit.redact", payload, actor));
1157
1732
  renameSync(tmp, path);
1158
- acknowledge(s.redacted);
1159
1733
  }
1160
1734
  }
1161
1735
  const live = auditPath();
@@ -1164,22 +1738,23 @@ export function scrubAuditLog(opts) {
1164
1738
  tally("audit.log", scrubText(readFileSync(live, "utf-8"), opts.redact));
1165
1739
  }
1166
1740
  else {
1167
- let liveAcks = [];
1168
1741
  const release = acquireLockSync();
1169
1742
  try {
1170
- const s = scrubText(readFileSync(live, "utf-8"), opts.redact);
1743
+ settleTail(live); // the log ends with a newline from here on
1744
+ const before = readFileSync(live);
1745
+ const s = scrubText(before.toString("utf-8"), opts.redact);
1171
1746
  tally("audit.log", s);
1172
1747
  if (s.redacted.length > 0) {
1748
+ acknowledge(s.redacted, (payload) => writeRecord(live, "audit.redact", payload, actor));
1749
+ const grown = readFileSync(live).subarray(before.length); // our acknowledgements, nothing else: we hold the lock
1173
1750
  const tmp = join(auditDir(), ".audit.log.scrub.tmp");
1174
- writeFileAndSync(tmp, s.body);
1751
+ writeFileAndSync(tmp, Buffer.concat([Buffer.from(s.body, "utf-8"), grown]));
1175
1752
  renameSync(tmp, live);
1176
- liveAcks = s.redacted;
1177
1753
  }
1178
1754
  }
1179
1755
  finally {
1180
1756
  release();
1181
1757
  }
1182
- acknowledge(liveAcks); // after the release: appendAudit takes the same lock
1183
1758
  }
1184
1759
  }
1185
1760
  return { ...report, applied: !!opts.apply };
@@ -1190,6 +1765,7 @@ export function scrubAuditLog(opts) {
1190
1765
  if ("heldMs" in lock)
1191
1766
  return { ...report, refusedReason: "a rotation is in progress; try again in a minute" };
1192
1767
  try {
1768
+ finishInterruptedMoves(); // [LOCK] [AN-INTERRUPTED-MOVE-IS-FINISHED]: no leftover temp keeps a secret
1193
1769
  return run();
1194
1770
  }
1195
1771
  finally {
@@ -1221,13 +1797,19 @@ export function resetCacheForTest() {
1221
1797
  // Safe wrapper that never throws into hot paths. Use this from production
1222
1798
  // call sites so a failed audit append cannot break a learning save or
1223
1799
  // session write.
1800
+ // Returns whether the entry was written. A refusal is counted in audit-refused.jsonl and chained
1801
+ // as `audit.append_failed` by the next append that succeeds. [LOCK] [A-REFUSED-APPEND-IS-COUNTED-AND-CHAINED]
1224
1802
  export function safeAppend(event, payload, actor = "system") {
1225
1803
  try {
1226
1804
  appendAudit(event, payload, actor);
1805
+ return true;
1227
1806
  }
1228
1807
  catch (e) {
1229
- // Last-resort surface — stderr only, never throw upward.
1230
- process.stderr.write(`[ContextEngine] audit append failed: ${e instanceof Error ? e.message : String(e)}\n`);
1808
+ const message = e instanceof Error ? e.message : String(e);
1809
+ // Never throw upward: stderr, plus the count the chain and fleet health will carry.
1810
+ process.stderr.write(`[ContextEngine] audit append failed: ${message}\n`);
1811
+ recordRefusal(event, message);
1812
+ return false;
1231
1813
  }
1232
1814
  }
1233
1815
  //# sourceMappingURL=audit.js.map