fraim 2.0.339 → 2.0.341

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.
@@ -10,6 +10,7 @@ exports.ruleUsageKey = ruleUsageKey;
10
10
  exports.resolveEntryIdentity = resolveEntryIdentity;
11
11
  exports.normalizeModelName = normalizeModelName;
12
12
  exports.readUsageSnapshot = readUsageSnapshot;
13
+ exports.resolveUsageCheckpointPath = resolveUsageCheckpointPath;
13
14
  exports.writeUsageSnapshot = writeUsageSnapshot;
14
15
  exports.foldUsageEvents = foldUsageEvents;
15
16
  exports.readUsageStore = readUsageStore;
@@ -46,18 +47,10 @@ exports.compactUsageEvents = compactUsageEvents;
46
47
  * (`src/ai-hub/server.ts`). That placement is what satisfies R18; there is no
47
48
  * second guardrail here.
48
49
  *
49
- * It is derived data (R17): deleting it loses no lesson, because every firing is
50
- * still written in the retrospective that attested it and every offer is
51
- * re-recorded the next time the entry is delivered.
52
- *
53
- * ## Issue #1752 — what changed and why
54
- *
55
- * The durable artifact is now the append-only event log in
56
- * `learning-usage-events.ts`; `usage/learning-usage.json` is a derived snapshot
57
- * carrying a `foldedThroughBytes` checkpoint into it. The old read-modify-write
58
- * lost 1589 of 1600 writes from eight concurrent processes on a plain local disk
59
- * (spike S1b), which is the real cause of both the impossible `fired > offered`
60
- * counts and the four OneDrive conflict copies.
50
+ * Sleep consolidates the append-only journal into learning-usage.json and its
51
+ * recoverable .checkpoint copy before clearing processed events. Those snapshots
52
+ * preserve lifetime aggregates and source bookmarks after journal cleanup.
53
+ * Raw retrospectives preserve lessons and attestations, not every runtime offer.
61
54
  *
62
55
  * Four smaller corrections ride with it, each removing a place where the record
63
56
  * gave a confident answer it could not prove:
@@ -237,6 +230,13 @@ function normalizeModelName(model) {
237
230
  function emptyStore() {
238
231
  return { version: STORE_VERSION, updatedAt: new Date(0).toISOString(), entries: {}, foldedThroughBytes: {}, offersEraStart: null };
239
232
  }
233
+ function withEvidence(store, evidence) {
234
+ Object.defineProperty(store, 'evidence', { value: evidence, enumerable: false, configurable: true });
235
+ return store;
236
+ }
237
+ function unavailableStore(observation) {
238
+ return withEvidence(emptyStore(), { status: 'unavailable', excludedKeys: [], observations: [observation] });
239
+ }
240
240
  /**
241
241
  * Is this parsed value a record every consumer can read without crashing?
242
242
  *
@@ -261,14 +261,21 @@ function isUsableRecord(value) {
261
261
  const fired = record.fired;
262
262
  if (!offered || typeof offered !== 'object' || !fired || typeof fired !== 'object')
263
263
  return false;
264
- return Array.isArray(offered.log)
265
- && typeof offered.total === 'number'
266
- && typeof offered.aboveThreshold === 'number'
267
- && !!offered.byModel && typeof offered.byModel === 'object'
268
- && Array.isArray(fired.log)
269
- && typeof fired.total === 'number'
270
- && !!fired.byOutcome && typeof fired.byOutcome === 'object'
271
- && Array.isArray(record.titles);
264
+ const count = (v) => Number.isSafeInteger(v) && v >= 0;
265
+ const counts = (v) => !!v && typeof v === 'object' && !Array.isArray(v)
266
+ && Object.values(v).every(count);
267
+ const nullableString = (v) => v === null || typeof v === 'string';
268
+ return typeof record.key === 'string' && (record.family === 'learning' || record.family === 'rule')
269
+ && Array.isArray(offered.log) && offered.log.every(r => r && isValidDate(r.date)
270
+ && typeof r.job === 'string' && nullableString(r.model))
271
+ && count(offered.total) && count(offered.aboveThreshold)
272
+ && counts(offered.byModel)
273
+ && Array.isArray(fired.log) && fired.log.every(r => r && isValidDate(r.date)
274
+ && typeof r.job === 'string' && nullableString(r.model) && exports.FIRING_OUTCOMES.includes(r.outcome))
275
+ && count(fired.total) && counts(fired.byOutcome) && counts(fired.byModel)
276
+ && exports.FIRING_OUTCOMES.every(outcome => count(fired.byOutcome[outcome]))
277
+ && Array.isArray(fired.seenSources) && fired.seenSources.every(s => typeof s === 'string')
278
+ && Array.isArray(record.titles) && record.titles.every(t => typeof t === 'string');
272
279
  }
273
280
  /**
274
281
  * The earliest offer date a pre-#1752 snapshot can evidence.
@@ -306,32 +313,63 @@ function readCheckpoints(value) {
306
313
  }
307
314
  return out;
308
315
  }
309
- /**
310
- * Parse the snapshot alone, with no event folding. A missing or corrupt snapshot
311
- * reads as empty: it is derived data, so losing it is recoverable and must never
312
- * break the loader that reads it.
313
- */
316
+ /** Read the normal snapshot, recovering a missing or torn copy from its checkpoint. */
314
317
  function readUsageSnapshot(storePath = resolveUsageStorePath()) {
318
+ const primary = parseUsageSnapshot(storePath);
319
+ if (primary && primary.evidence?.status !== 'unavailable')
320
+ return primary;
321
+ // An unknown schema is not evidence that an older checkpoint can replace it.
322
+ if (primary?.evidence?.observations.includes('unsupported-schema'))
323
+ return primary;
324
+ const backup = parseUsageSnapshot(resolveUsageCheckpointPath(storePath));
325
+ if (backup && !backup.evidence)
326
+ return withEvidence(backup, {
327
+ status: 'reliable', excludedKeys: [],
328
+ observations: [(0, fs_1.existsSync)(storePath) ? 'primary-unreadable' : 'primary-missing'],
329
+ recovery: 'checkpoint', persisted: false,
330
+ });
331
+ if ((0, fs_1.existsSync)(resolveUsageCheckpointPath(storePath))) {
332
+ throw new Error('Usage snapshot and checkpoint backup are unreadable. Restore a matching backup; do not delete the usage directory.');
333
+ }
334
+ return primary ?? emptyStore();
335
+ }
336
+ function resolveUsageCheckpointPath(storePath) {
337
+ return storePath + '.checkpoint';
338
+ }
339
+ function parseUsageSnapshot(storePath) {
315
340
  try {
316
341
  if (!(0, fs_1.existsSync)(storePath))
317
- return emptyStore();
342
+ return null;
318
343
  const parsed = JSON.parse((0, fs_1.readFileSync)(storePath, 'utf8'));
319
344
  // `typeof [] === 'object'`, so an array has to be rejected explicitly: otherwise
320
345
  // Object.values yields whatever the array held and every consumer that reads
321
346
  // `entry.offered` throws on it.
322
347
  if (!parsed || typeof parsed !== 'object' || !parsed.entries
323
348
  || typeof parsed.entries !== 'object' || Array.isArray(parsed.entries)) {
324
- return emptyStore();
349
+ return unavailableStore('malformed-snapshot');
350
+ }
351
+ if (parsed.version !== undefined && parsed.version !== STORE_VERSION)
352
+ return unavailableStore('unsupported-schema');
353
+ if (parsed.foldedThroughBytes !== undefined && (!parsed.foldedThroughBytes
354
+ || typeof parsed.foldedThroughBytes !== 'object' || Array.isArray(parsed.foldedThroughBytes)
355
+ || Object.values(parsed.foldedThroughBytes).some(v => !Number.isSafeInteger(v) || v < 0))) {
356
+ return unavailableStore('malformed-checkpoints');
325
357
  }
326
358
  // Drop any value that is not a usable record rather than handing a caller
327
359
  // something it will crash on. A partially corrupt store degrades to the records
328
- // that are still readable, which is the right behaviour for derived data.
360
+ // that are still readable before consolidation. Consolidated snapshots must be complete.
329
361
  const entries = {};
362
+ const excludedKeys = [];
330
363
  for (const [key, value] of Object.entries(parsed.entries)) {
331
- if (isUsableRecord(value))
364
+ if (isUsableRecord(value) && value.key === key)
332
365
  entries[key] = value;
366
+ else if (parsed.consolidated)
367
+ return unavailableStore('malformed-consolidated-snapshot');
368
+ else
369
+ excludedKeys.push(key);
333
370
  }
334
- return {
371
+ const store = {
372
+ consolidated: parsed.consolidated === true,
335
373
  version: parsed.version ?? STORE_VERSION,
336
374
  updatedAt: parsed.updatedAt ?? emptyStore().updatedAt,
337
375
  entries,
@@ -340,30 +378,26 @@ function readUsageSnapshot(storePath = resolveUsageStorePath()) {
340
378
  ? parsed.offersEraStart
341
379
  : deriveOffersEraStart(entries),
342
380
  };
381
+ return excludedKeys.length ? withEvidence(store, {
382
+ status: 'partial', excludedKeys, observations: ['malformed-records'],
383
+ }) : store;
343
384
  }
344
385
  catch {
345
- return emptyStore();
386
+ return unavailableStore('unreadable-snapshot');
346
387
  }
347
388
  }
348
389
  /**
349
- * Publish the derived snapshot.
350
- *
351
- * In place, with a plain `writeFileSync`, and never by rename. S3 measured a
352
- * rename over a target any process holds open failing EPERM 100 times in 100, on
353
- * both a synced and a local path, because that is Windows file-sharing semantics
354
- * rather than a OneDrive artefact. The usage store is read by concurrent MCP
355
- * processes by design, so a rename-published snapshot would fail essentially
356
- * always in production. This is safe precisely because the snapshot is derived: a
357
- * torn one is rejected by `readUsageSnapshot` and rebuilt from the log.
358
- *
359
- * Returns whether it landed. A failure is never thrown: the log is authoritative
360
- * and the next reader either uses the older snapshot with a longer tail, or folds
361
- * cold.
390
+ * Refresh the normal snapshot in place (Windows readers can prevent rename).
391
+ * Sleep alone publishes the separate flushed checkpoint backup before clearing
392
+ * journals, so a torn ordinary refresh can recover without replaying old history.
362
393
  */
363
394
  function writeUsageSnapshot(store, storePath = resolveUsageStorePath()) {
395
+ if (store.evidence && store.evidence.status !== 'reliable')
396
+ return false;
364
397
  try {
365
398
  (0, fs_1.mkdirSync)((0, path_1.dirname)(storePath), { recursive: true });
366
- (0, fs_1.writeFileSync)(storePath, `${JSON.stringify(store, null, 2)}\n`, 'utf8');
399
+ const { evidence: _evidence, ...snapshot } = store;
400
+ (0, fs_1.writeFileSync)(storePath, `${JSON.stringify(snapshot, null, 2)}\n`, { encoding: 'utf8', flush: true });
367
401
  return true;
368
402
  }
369
403
  catch {
@@ -385,26 +419,39 @@ function foldUsageEvents(options) {
385
419
  // A checkpoint past the end of its log means the log was compacted or replaced
386
420
  // under this snapshot, so the snapshot may already count events the log no
387
421
  // longer holds. Re-folding onto it would double count. Discard and fold cold.
422
+ if (tail.restarted && base.consolidated)
423
+ throw new Error('Usage journal no longer matches its consolidated snapshot. Restore matching checkpoint files before continuing.');
388
424
  if (tail.restarted) {
389
425
  const cold = (0, learning_usage_events_1.readEventsSince)(options.eventDir, {});
390
- return applyTail(emptyStore(), cold, limits, now, true);
426
+ return applyTail(withEvidence(emptyStore(), {
427
+ status: 'unavailable', excludedKeys: base.evidence?.excludedKeys ?? [],
428
+ observations: [...(base.evidence?.observations ?? []), 'journal-restarted'],
429
+ }), cold, limits, now, true);
391
430
  }
392
431
  return applyTail(base, tail, limits, now, false);
393
432
  }
394
433
  function applyTail(base, tail, limits, now, restarted) {
395
434
  const store = {
435
+ consolidated: base.consolidated,
396
436
  version: base.version,
397
437
  updatedAt: base.updatedAt,
398
438
  entries: base.entries,
399
439
  foldedThroughBytes: { ...(base.foldedThroughBytes ?? {}) },
400
440
  offersEraStart: base.offersEraStart ?? null,
401
441
  };
442
+ if (base.evidence)
443
+ withEvidence(store, base.evidence);
402
444
  let tailBytes = 0;
445
+ let unsupportedEvents = false;
403
446
  const touched = new Set();
404
447
  for (const read of tail.reads) {
405
448
  const from = restarted ? 0 : (store.foldedThroughBytes[read.machineId] ?? 0);
406
449
  tailBytes += Math.max(0, read.through - from);
407
450
  for (const event of read.records) {
451
+ if (!isSupportedUsageEvent(event)) {
452
+ unsupportedEvents = true;
453
+ continue;
454
+ }
408
455
  const key = applyEvent(store, event, limits, now);
409
456
  if (key)
410
457
  touched.add(key);
@@ -419,28 +466,90 @@ function applyTail(base, tail, limits, now, restarted) {
419
466
  // E: the invariant is evaluated on the read path, where it is always current
420
467
  // and costs the write path nothing.
421
468
  flagIntegrity(store);
469
+ const incompleteJournal = tail.skippedLines > 0
470
+ || tail.reads.some(read => read.through < read.baseOffset + read.size - read.headerBytes);
471
+ if (incompleteJournal || unsupportedEvents) {
472
+ withEvidence(store, {
473
+ status: 'unavailable', excludedKeys: store.evidence?.excludedKeys ?? [],
474
+ observations: [...(store.evidence?.observations ?? []),
475
+ ...(incompleteJournal ? ['incomplete-journal'] : []),
476
+ ...(unsupportedEvents ? ['unsupported-journal-events'] : [])],
477
+ });
478
+ }
422
479
  return { store, skippedLines: tail.skippedLines, restarted, tailBytes, logs: tail.reads };
423
480
  }
424
481
  /**
425
482
  * The checkpointed read every consumer uses. Snapshot plus the bytes appended
426
483
  * past it, republished once the tail is worth re-checkpointing.
427
484
  */
485
+ /** Metadata detects observed writes/sync during a read; it is not a writer lock. */
486
+ function usageReadState(storePath, eventDir) {
487
+ const dir = (0, path_1.dirname)(storePath);
488
+ const copies = (0, fs_1.existsSync)(dir) ? (0, fs_1.readdirSync)(dir)
489
+ .filter(name => /^learning-usage(-.+)?\.json$/.test(name) && name !== STORE_FILE_NAME).sort() : [];
490
+ const paths = [storePath, resolveUsageCheckpointPath(storePath),
491
+ ...(0, learning_usage_events_1.listEventLogs)(eventDir).map(log => log.path), ...copies.map(name => (0, path_1.join)(dir, name))];
492
+ const fingerprint = JSON.stringify(paths.map(file => {
493
+ try {
494
+ const stat = (0, fs_1.statSync)(file);
495
+ return [file, stat.size, stat.mtimeMs, stat.ctimeMs, stat.ino];
496
+ }
497
+ catch {
498
+ return [file, 'missing'];
499
+ }
500
+ }));
501
+ return { fingerprint, copies };
502
+ }
428
503
  function readUsageStore(storePath = resolveUsageStorePath(), options = {}) {
429
- const snapshot = readUsageSnapshot(storePath);
430
504
  const eventDir = options.eventDir ?? (0, learning_usage_events_1.resolveEventDir)(storePath);
431
- if ((0, learning_usage_events_1.listEventLogs)(eventDir).length === 0)
432
- return snapshot;
433
- const folded = foldUsageEvents({
505
+ const before = usageReadState(storePath, eventDir);
506
+ const snapshot = readUsageSnapshot(storePath);
507
+ const logs = (0, learning_usage_events_1.listEventLogs)(eventDir);
508
+ const finish = (store) => {
509
+ const after = usageReadState(storePath, eventDir);
510
+ const observations = [...(store.evidence?.observations ?? [])];
511
+ if (before.fingerprint !== after.fingerprint)
512
+ observations.push('inputs-changed-during-read');
513
+ if (after.copies.length)
514
+ observations.push('unmerged-store-copies');
515
+ if (store.consolidated && Object.keys(store.foldedThroughBytes ?? {}).some(machineId => !logs.some(log => log.machineId === machineId)))
516
+ observations.push('consolidated-journal-missing');
517
+ if (observations.length > (store.evidence?.observations.length ?? 0))
518
+ withEvidence(store, {
519
+ status: 'unavailable', excludedKeys: store.evidence?.excludedKeys ?? [], observations,
520
+ });
521
+ flagIntegrity(store);
522
+ return store;
523
+ };
524
+ if (logs.length === 0 || snapshot.evidence?.observations.includes('unsupported-schema'))
525
+ return finish(snapshot);
526
+ const fold = (base) => foldUsageEvents({
434
527
  eventDir,
435
528
  limits: options.limits,
436
529
  now: options.now,
437
- base: snapshot,
438
- checkpoints: snapshot.foldedThroughBytes ?? {},
530
+ base,
531
+ checkpoints: base.foldedThroughBytes ?? {},
439
532
  });
533
+ let folded;
534
+ try {
535
+ folded = fold(snapshot);
536
+ }
537
+ catch (error) {
538
+ const backup = parseUsageSnapshot(resolveUsageCheckpointPath(storePath));
539
+ if (!backup || backup.evidence)
540
+ throw error;
541
+ folded = fold(withEvidence(backup, {
542
+ status: 'reliable', excludedKeys: [], observations: ['primary-journal-mismatch'],
543
+ recovery: 'checkpoint', persisted: false,
544
+ }));
545
+ }
546
+ finish(folded.store);
440
547
  const mode = options.refreshSnapshot ?? 'auto';
441
548
  const shouldRefresh = mode === 'always'
442
549
  || (mode === 'auto' && (folded.tailBytes >= SNAPSHOT_REFRESH_BYTES || !(0, fs_1.existsSync)(storePath) || folded.restarted));
443
- if (shouldRefresh) {
550
+ // Recovery is usable in memory. Persisting a primary replacement requires the
551
+ // explicit maintenance interval; an ordinary reader never establishes it.
552
+ if (shouldRefresh && !folded.store.evidence) {
444
553
  folded.store.updatedAt = (options.now ?? new Date()).toISOString();
445
554
  writeUsageSnapshot(folded.store, storePath);
446
555
  }
@@ -627,6 +736,20 @@ function applyEvent(store, event, limits, now) {
627
736
  void now;
628
737
  return null;
629
738
  }
739
+ function isSupportedUsageEvent(event) {
740
+ if (typeof event.key !== 'string' || !event.key || typeof event.date !== 'string' || !isValidDate(event.date))
741
+ return false;
742
+ if (event.family !== 'learning' && event.family !== 'rule')
743
+ return false;
744
+ if (typeof event.job !== 'string' || (event.model !== null && typeof event.model !== 'string'))
745
+ return false;
746
+ if (event.kind === 'offer')
747
+ return typeof event.aboveThreshold === 'boolean';
748
+ if (event.kind === 'firing')
749
+ return exports.FIRING_OUTCOMES.includes(event.outcome)
750
+ && (event.source === undefined || event.source === 'attested' || event.source === 'backfill-legacy');
751
+ return false;
752
+ }
630
753
  function applyOfferEvent(store, event, common) {
631
754
  const record = ensureRecord(store, common.key, common.family, common.fileType, common.level);
632
755
  rememberTitle(record, typeof event.title === 'string' ? event.title : undefined);
@@ -747,7 +870,7 @@ function flagIntegrity(store) {
747
870
  /**
748
871
  * Materialize the snapshot when there is none.
749
872
  *
750
- * The snapshot is derived and normally published by readers, but a workspace that
873
+ * Before the first consolidation, readers publish snapshots, but a workspace that
751
874
  * has only ever been written to would have no snapshot at all, so every reader
752
875
  * would pay a cold fold and anything inspecting the store as a file would find
753
876
  * nothing there. Bounded: it only runs when the file is absent, and at that point
@@ -761,7 +884,7 @@ function ensureSnapshotExists(storePath, eventDir, limits, now) {
761
884
  readUsageStore(storePath, { limits, now, eventDir, refreshSnapshot: 'always' });
762
885
  }
763
886
  catch {
764
- // The log is authoritative. A missing snapshot costs a cold fold, nothing else.
887
+ // Initial cache publication can retry; consolidated history has a separate backup.
765
888
  }
766
889
  }
767
890
  /**
@@ -874,19 +997,17 @@ function noteTitleChange(key, newTitle, options) {
874
997
  store.updatedAt = (options.now ?? new Date()).toISOString();
875
998
  writeUsageSnapshot(store, storePath);
876
999
  }
877
- /**
878
- * Apply retention and log bounds across the whole store, and compact the event
879
- * log behind it.
880
- *
881
- * Compaction is a requirement rather than an optimization: S4 measured a cold
882
- * full fold at 84 ms on today's volume but 794 ms at ten times it, and the
883
- * checkpoint only makes a cold fold rare, not bounded.
884
- */
885
1000
  function pruneUsageStore(limits, options = {}) {
886
1001
  const storePath = options.storePath ?? resolveUsageStorePath();
887
1002
  const eventDir = options.eventDir ?? (0, learning_usage_events_1.resolveEventDir)(storePath);
888
1003
  const now = options.now ?? new Date();
889
1004
  const store = readUsageStore(storePath, { limits, now, eventDir });
1005
+ if (store.evidence && store.evidence.status !== 'reliable')
1006
+ return {
1007
+ maintenanceStatus: 'deferred', evidence: store.evidence,
1008
+ prunedOfferRecords: 0, prunedFiringRecords: 0, entries: Object.keys(store.entries).length,
1009
+ compaction: compactUsageEvents({ eventDir, snapshotPath: storePath, limits, now, store }),
1010
+ };
890
1011
  let prunedOfferRecords = 0;
891
1012
  let prunedFiringRecords = 0;
892
1013
  for (const record of Object.values(store.entries)) {
@@ -897,28 +1018,63 @@ function pruneUsageStore(limits, options = {}) {
897
1018
  prunedFiringRecords += firingsBefore - record.fired.log.length;
898
1019
  }
899
1020
  const compaction = compactUsageEvents({ eventDir, snapshotPath: storePath, limits, now, store });
900
- return { prunedOfferRecords, prunedFiringRecords, entries: Object.keys(store.entries).length, compaction };
1021
+ return {
1022
+ maintenanceStatus: compaction.maintenanceStatus, evidence: compaction.evidence,
1023
+ prunedOfferRecords, prunedFiringRecords, entries: Object.keys(store.entries).length, compaction,
1024
+ };
901
1025
  }
902
- /**
903
- * Rewrite the event logs to the configured retention window and re-base the
904
- * snapshot's checkpoint onto the rewritten logs.
905
- *
906
- * Re-basing is not optional: leaving a checkpoint pointing into the old log would
907
- * make the next reader either re-count events the snapshot already holds, or skip
908
- * ones it does not.
1026
+ /** Save totals and source bookmarks in both copies, then clear processed journals.
1027
+ * Requires an interval without concurrent writers or sync activity.
909
1028
  */
910
1029
  function compactUsageEvents(options) {
911
1030
  const now = options.now ?? new Date();
912
1031
  const store = options.store
913
1032
  ?? readUsageStore(options.snapshotPath, { limits: options.limits, now, eventDir: options.eventDir });
914
- const result = (0, learning_usage_events_1.compactEventLogs)(options.eventDir, (event) => {
1033
+ // Do not turn a partial read into a durable replacement or advance bookmarks
1034
+ // over evidence this reader could not understand. Independent review can run.
1035
+ if (store.evidence && store.evidence.status !== 'reliable')
1036
+ return {
1037
+ maintenanceStatus: 'deferred', evidence: store.evidence,
1038
+ scanned: 0, kept: 0, dropped: 0, skippedLines: 0, logs: [],
1039
+ checkpoints: { ...(store.foldedThroughBytes ?? {}) },
1040
+ };
1041
+ for (const log of (0, learning_usage_events_1.listEventLogs)(options.eventDir)) {
1042
+ if ((store.foldedThroughBytes?.[log.machineId] ?? 0) < log.baseOffset) {
1043
+ throw new Error('Cannot consolidate from a snapshot older than the journal checkpoint.');
1044
+ }
1045
+ }
1046
+ for (const entry of Object.values(store.entries))
1047
+ trimLogs(entry, options.limits, now);
1048
+ store.consolidated = true;
1049
+ store.updatedAt = now.toISOString();
1050
+ // Publish the recoverable checkpoint first, then the normal snapshot. No
1051
+ // journal is cleared unless both writes succeed. The backup is never written
1052
+ // by ordinary readers, so a torn cache refresh cannot destroy both copies.
1053
+ const checkpoint = resolveUsageCheckpointPath(options.snapshotPath);
1054
+ const temp = checkpoint + '.tmp';
1055
+ try {
1056
+ (0, fs_1.mkdirSync)((0, path_1.dirname)(checkpoint), { recursive: true });
1057
+ const { evidence: _evidence, ...snapshot } = store;
1058
+ (0, fs_1.writeFileSync)(temp, JSON.stringify(snapshot, null, 2) + '\n', { encoding: 'utf8', flush: true });
1059
+ (0, fs_1.renameSync)(temp, checkpoint);
1060
+ }
1061
+ catch {
1062
+ try {
1063
+ (0, fs_1.rmSync)(temp, { force: true });
1064
+ }
1065
+ catch { /* Best effort. */ }
1066
+ throw new Error('Cannot consolidate usage events: checkpoint backup could not be saved.');
1067
+ }
1068
+ if (!writeUsageSnapshot(store, options.snapshotPath)) {
1069
+ throw new Error('Cannot consolidate usage events: aggregate snapshot could not be saved.');
1070
+ }
1071
+ const compaction = (0, learning_usage_events_1.compactEventLogs)(options.eventDir, (event) => {
1072
+ if (event.kind !== 'offer' && event.kind !== 'firing')
1073
+ return true;
915
1074
  const date = typeof event.date === 'string' ? event.date : '';
916
- if (!isValidDate(date))
1075
+ if (typeof event.key !== 'string' || !event.key || !isValidDate(date))
917
1076
  return true;
918
- return withinRetention(date, options.limits.retentionDays, now);
919
- });
920
- store.foldedThroughBytes = Object.fromEntries(result.logs.map((log) => [log.machineId, log.size]));
921
- store.updatedAt = now.toISOString();
922
- writeUsageSnapshot(store, options.snapshotPath);
923
- return result;
1077
+ return event.kind === 'firing' && !exports.FIRING_OUTCOMES.includes(event.outcome);
1078
+ }, store.foldedThroughBytes ?? {});
1079
+ return { ...compaction, maintenanceStatus: 'completed', evidence: store.evidence };
924
1080
  }
@@ -16,37 +16,23 @@ exports.buildVerifyReport = buildVerifyReport;
16
16
  * It reports and never blocks. Nothing here treats a finding as a gate: it states
17
17
  * what it found, and the manager decides.
18
18
  *
19
- * ## Why there is no repair command here
19
+ * ## Recovery boundary
20
20
  *
21
- * The usage record is derived data (#1103 R17): every firing is also written in
22
- * the retrospective that attested it, and every offer is re-recorded the next
23
- * time the entry is delivered. So the repair for a store that has gone wrong is
24
- * to delete it and rebuild, not to reconcile it in place.
25
- *
26
- * That is not a preference, it is what the numbers say. Measured on a 733-file
27
- * retrospective corpus with four unmerged store copies beside the canonical one:
28
- *
29
- * delete `usage/` then `learning-usage backfill` -> 149 entries, 2874 firings,
30
- * 0 split identities,
31
- * 1 clean model bucket
32
- *
33
- * A rebuild lands with zero split identities and one model bucket for free,
34
- * because the identity resolution and the model normalization fix the causes and
35
- * a rebuilt store carries no legacy shape. The only thing an in-place
36
- * reconciliation recovers beyond that is old firings whose retrospectives
37
- * retention has already deleted, and a decay clock reads the most recent firing
38
- * rather than the count. What a rebuild does cost is the offer history, which
39
- * rebuilds itself from live traffic and degrades toward `undetermined` and
40
- * hold-back in the meantime, which is this module's posture anyway.
41
- *
42
- * So `verify` names the rebuild when it finds a stale shape, and the destructive
43
- * step stays the manager's.
21
+ * Rebuild only the snapshot, keeping the event directory intact. The retained
22
+ * events recover the offers and firings they cover; retrospective backfill can
23
+ * supplement older firings but cannot recreate offers. Normal compaction keeps
24
+ * lifetime aggregate totals in the snapshot, so a backup is required before
25
+ * discarding it if history already pruned from events must remain inspectable.
26
+ * Rebuilding cannot invent missing history or fix identities still present in
27
+ * the authoritative events. Verify reports those limitations rather than
28
+ * claiming that deleting the usage directory is a lossless repair.
44
29
  */
45
30
  const fs_1 = require("fs");
46
31
  const path_1 = require("path");
47
32
  const learning_usage_store_1 = require("./learning-usage-store");
48
33
  const learning_usage_events_1 = require("./learning-usage-events");
49
34
  const learning_usage_projection_1 = require("./learning-usage-projection");
35
+ const learning_usage_eligibility_1 = require("./learning-usage-eligibility");
50
36
  const CANONICAL_FILE_NAME = 'learning-usage.json';
51
37
  /** `learning-usage.json` plus anything a sync client forked off it. */
52
38
  const STORE_COPY_PATTERN = /^learning-usage(-.+)?\.json$/;
@@ -179,7 +165,7 @@ function scanRecordsForIntegrity(records, offersEraStart) {
179
165
  // unrecorded. An entry whose outcomes are all `ignored` has an empty bucket
180
166
  // set correctly, and that is information rather than a gap.
181
167
  const clockAdvancing = record.fired.byOutcome.prevented + record.fired.byOutcome.applied;
182
- if (clockAdvancing > 0 && Object.keys(record.fired.byModel).length === 0) {
168
+ if ((0, learning_usage_eligibility_1.modelAttributionIncomplete)(record)) {
183
169
  unknownModelAttribution.push({ key: record.key, title, fired: clockAdvancing });
184
170
  }
185
171
  }
@@ -198,6 +184,7 @@ function buildVerifyReport(options) {
198
184
  const checkpoints = snapshot.foldedThroughBytes ?? {};
199
185
  const totalLogBytes = logs.reduce((sum, log) => sum + log.size, 0);
200
186
  return {
187
+ evidence: store.evidence,
201
188
  snapshotPath: options.snapshotPath,
202
189
  eventDir,
203
190
  currentModel: (0, learning_usage_projection_1.resolveCurrentModel)(options.currentModel),
@@ -212,7 +199,7 @@ function buildVerifyReport(options) {
212
199
  // The one number that makes a stale read visible: a snapshot that stops
213
200
  // advancing while the log grows is a failing snapshot write, which S3 showed
214
201
  // will happen intermittently on a synced folder and is otherwise silent.
215
- checkpointLag: Object.fromEntries(logs.map((log) => [log.machineId, Math.max(0, log.size - (checkpoints[log.machineId] ?? 0))])),
202
+ checkpointLag: Object.fromEntries(logs.map((log) => [log.machineId, Math.max(0, log.baseOffset + log.size - log.headerBytes - (checkpoints[log.machineId] ?? 0))])),
216
203
  skippedLines: 0,
217
204
  totalLogBytes,
218
205
  compactionAdvisoryBytes: exports.COMPACTION_ADVISORY_BYTES,