fraim 2.0.337 → 2.0.339

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.
@@ -1,17 +1,25 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.CLOCK_ADVANCING_OUTCOMES = exports.FIRING_OUTCOMES = void 0;
3
+ exports.appendEvents = exports.listEventLogs = exports.resolveMachineId = exports.resolveEventDir = exports.AMBIENT_JOB_LABELS = exports.CLOCK_ADVANCING_OUTCOMES = exports.FIRING_OUTCOMES = void 0;
4
4
  exports.resolveUsageStorePath = resolveUsageStorePath;
5
5
  exports.resolveUsageLimits = resolveUsageLimits;
6
+ exports.defaultUsageLimits = defaultUsageLimits;
6
7
  exports.normalizeEntryTitle = normalizeEntryTitle;
7
8
  exports.titleUsageKey = titleUsageKey;
8
9
  exports.ruleUsageKey = ruleUsageKey;
10
+ exports.resolveEntryIdentity = resolveEntryIdentity;
11
+ exports.normalizeModelName = normalizeModelName;
12
+ exports.readUsageSnapshot = readUsageSnapshot;
13
+ exports.writeUsageSnapshot = writeUsageSnapshot;
14
+ exports.foldUsageEvents = foldUsageEvents;
9
15
  exports.readUsageStore = readUsageStore;
10
16
  exports.sanitizeNote = sanitizeNote;
17
+ exports.integrityViolation = integrityViolation;
11
18
  exports.recordOffers = recordOffers;
12
19
  exports.recordFirings = recordFirings;
13
20
  exports.noteTitleChange = noteTitleChange;
14
21
  exports.pruneUsageStore = pruneUsageStore;
22
+ exports.compactUsageEvents = compactUsageEvents;
15
23
  /**
16
24
  * Issue #1103 — the usage record for learnings and rules.
17
25
  *
@@ -41,12 +49,44 @@ exports.pruneUsageStore = pruneUsageStore;
41
49
  * It is derived data (R17): deleting it loses no lesson, because every firing is
42
50
  * still written in the retrospective that attested it and every offer is
43
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.
61
+ *
62
+ * Four smaller corrections ride with it, each removing a place where the record
63
+ * gave a confident answer it could not prove:
64
+ *
65
+ * A1 `resolveEntryIdentity` is the one definition of what an entry is. Four
66
+ * call sites each built `id ?? titleUsageKey(...)` independently, so one
67
+ * lesson could be two records.
68
+ * C2 Model names are lowercased at write. `GPT-5` and `gpt-5` were two buckets
69
+ * splitting one denominator (measured 6857 against 2652).
70
+ * D3 Job-labelled offers are reserved against eviction by session traffic.
71
+ * 2067 of 2354 retained offer records were `session` or `__freeform__`, so
72
+ * "was this in context for that job" had almost no resolution left.
73
+ * E A scoped integrity invariant. The literal `fired <= offered` the issue
74
+ * asks for is unsound, because `runBackfill` legitimately writes firings
75
+ * for 299 retrospectives that predate offer recording. Scoped to attested
76
+ * firings dated at or after the store's first recorded offer, it holds: the
77
+ * CLI throws on a violation, and the injection path only flags, because a
78
+ * usage failure must never change what the agent is given (R2).
44
79
  */
45
80
  const fs_1 = require("fs");
46
81
  const path_1 = require("path");
47
82
  const pack_home_1 = require("../cli/utils/pack-home");
48
83
  const artifact_retention_cleanup_1 = require("./artifact-retention-cleanup");
49
84
  const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
85
+ const learning_usage_events_1 = require("./learning-usage-events");
86
+ Object.defineProperty(exports, "appendEvents", { enumerable: true, get: function () { return learning_usage_events_1.appendEvents; } });
87
+ Object.defineProperty(exports, "listEventLogs", { enumerable: true, get: function () { return learning_usage_events_1.listEventLogs; } });
88
+ Object.defineProperty(exports, "resolveEventDir", { enumerable: true, get: function () { return learning_usage_events_1.resolveEventDir; } });
89
+ Object.defineProperty(exports, "resolveMachineId", { enumerable: true, get: function () { return learning_usage_events_1.resolveMachineId; } });
50
90
  // ── Vocabulary ───────────────────────────────────────────────────────────────
51
91
  /**
52
92
  * The five outcomes (spec: Outcome vocabulary). `ignored` and `not-offered` are
@@ -69,10 +109,29 @@ const DEFAULT_FIRING_LOG_LIMIT = 20;
69
109
  * needs per-job offer detail rather than a counter.
70
110
  */
71
111
  const DEFAULT_OFFER_LOG_LIMIT = 50;
112
+ /**
113
+ * Issue #1752 D3. Of the offer log's slots, this many are held for records that
114
+ * carry a real job label, so `fraim_connect`'s per-session traffic cannot evict
115
+ * the only records that can answer R7.
116
+ */
117
+ const DEFAULT_JOB_OFFER_RESERVE = 25;
72
118
  /** Open Question 2: configurable and greater than a handful. */
73
119
  const DEFAULT_RETIREMENT_OFFER_THRESHOLD = 10;
120
+ /**
121
+ * Job labels the runtime writes when no job is selected. An offer carrying one of
122
+ * these says the entry was in a session's context, not that it was in context for
123
+ * any particular job, so it is not what R7 resolves against.
124
+ */
125
+ exports.AMBIENT_JOB_LABELS = ['session', '__freeform__', 'unknown', ''];
74
126
  /** R16: one line, bounded, no fences. Long enough for a sentence, short enough to be useless as a transcript. */
75
127
  const MAX_NOTE_LENGTH = 240;
128
+ /**
129
+ * Republish the snapshot once the unfolded tail passes this. Small enough that a
130
+ * cold fold stays rare, large enough that `fraim_connect` is not rewriting a
131
+ * 1 MB file on every call. At the measured ~250 bytes per event this is roughly
132
+ * two thousand events, which S4b folds in about 10 ms.
133
+ */
134
+ const SNAPSHOT_REFRESH_BYTES = 512 * 1024;
76
135
  // ── Paths and config ─────────────────────────────────────────────────────────
77
136
  /**
78
137
  * The store path, inside the manager layer so the existing git-backend guardrail
@@ -108,10 +167,25 @@ function resolveUsageLimits(workspaceRoot) {
108
167
  return {
109
168
  firingLogLimit: positiveInt(block.firingLogLimit, DEFAULT_FIRING_LOG_LIMIT),
110
169
  offerLogLimit: positiveInt(block.offerLogLimit, DEFAULT_OFFER_LOG_LIMIT),
170
+ jobOfferReserve: positiveInt(block.jobOfferReserve, DEFAULT_JOB_OFFER_RESERVE),
111
171
  retirementOfferThreshold: positiveInt(block.retirementOfferThreshold, DEFAULT_RETIREMENT_OFFER_THRESHOLD),
112
172
  retentionDays: typeof retentionDays === 'number' ? retentionDays : -1,
113
173
  };
114
174
  }
175
+ /**
176
+ * Bounds for a read that has no workspace to resolve them from. Retention is -1
177
+ * on purpose: a fold must not silently drop log records against a guessed
178
+ * retention window. Pruning is `pruneUsageStore`'s job and is explicit.
179
+ */
180
+ function defaultUsageLimits() {
181
+ return {
182
+ firingLogLimit: DEFAULT_FIRING_LOG_LIMIT,
183
+ offerLogLimit: DEFAULT_OFFER_LOG_LIMIT,
184
+ jobOfferReserve: DEFAULT_JOB_OFFER_RESERVE,
185
+ retirementOfferThreshold: DEFAULT_RETIREMENT_OFFER_THRESHOLD,
186
+ retentionDays: -1,
187
+ };
188
+ }
115
189
  // ── Keys ─────────────────────────────────────────────────────────────────────
116
190
  /** Normalize a title for keying. Case and whitespace only: never a similarity measure (D4). */
117
191
  function normalizeEntryTitle(title) {
@@ -129,9 +203,39 @@ function titleUsageKey(fileType, title) {
129
203
  function ruleUsageKey(displayPath) {
130
204
  return `R:${displayPath.replace(/\\/g, '/')}`;
131
205
  }
206
+ /**
207
+ * Issue #1752 A1: the single definition of what an entry is.
208
+ *
209
+ * `enumerateFileEntries`, `buildEntryIndex`, `lookupUsage` and the `classify` CLI
210
+ * each built `id ?? titleUsageKey(...)` for themselves, which is four definitions
211
+ * that can drift. Defect 4 is what drift looks like: one lesson stamped in one
212
+ * tier and unstamped in another became two records, one collecting every offer
213
+ * and the other every firing, and `standing` then recommended promoting and
214
+ * demoting the same lesson in a single response.
215
+ *
216
+ * The id always wins. A title key is a fallback for a lesson no tier has stamped,
217
+ * never a second identity for one that has been.
218
+ */
219
+ function resolveEntryIdentity(input) {
220
+ const id = typeof input.id === 'string' ? input.id.trim() : '';
221
+ if (id)
222
+ return { key: id, source: 'id' };
223
+ return { key: titleUsageKey(input.fileType ?? 'unknown', input.title ?? ''), source: 'title' };
224
+ }
225
+ /**
226
+ * Issue #1752 C2: one model, one bucket.
227
+ *
228
+ * Measured in the live store: `GPT-5` 6857 against `gpt-5` 2652, and `gpt-6` 1764
229
+ * against `GPT-6` 140. Every per-model calculation, including retirement
230
+ * candidacy, was splitting its own denominator.
231
+ */
232
+ function normalizeModelName(model) {
233
+ const normalized = String(model ?? '').trim().toLowerCase();
234
+ return normalized || null;
235
+ }
132
236
  // ── Read and write ───────────────────────────────────────────────────────────
133
237
  function emptyStore() {
134
- return { version: STORE_VERSION, updatedAt: new Date(0).toISOString(), entries: {} };
238
+ return { version: STORE_VERSION, updatedAt: new Date(0).toISOString(), entries: {}, foldedThroughBytes: {}, offersEraStart: null };
135
239
  }
136
240
  /**
137
241
  * Is this parsed value a record every consumer can read without crashing?
@@ -167,10 +271,47 @@ function isUsableRecord(value) {
167
271
  && Array.isArray(record.titles);
168
272
  }
169
273
  /**
170
- * A missing or corrupt store reads as empty. It is derived data, so losing it is
171
- * recoverable and must never break the loader that reads it.
274
+ * The earliest offer date a pre-#1752 snapshot can evidence.
275
+ *
276
+ * Found by validating against a copy of the real store: without this, a store
277
+ * written before the event log existed has no `offersEraStart`, the scoped
278
+ * invariant has no era to scope to, and it reports zero violations on a corpus
279
+ * that has 62 entries holding more attested firings than recorded offers. Silent
280
+ * and wrong, which is the failure class this whole issue is about.
281
+ *
282
+ * Derived from the bounded offer logs, so it is at or later than the true first
283
+ * offer. Later means fewer firings counted as in-era, which under-reports rather
284
+ * than inventing a violation, and that is the right direction for a check whose
285
+ * CLI form refuses a write.
286
+ */
287
+ function deriveOffersEraStart(entries) {
288
+ let earliest = null;
289
+ for (const record of Object.values(entries)) {
290
+ for (const offer of record.offered.log) {
291
+ if (!offer?.date)
292
+ continue;
293
+ if (earliest === null || offer.date < earliest)
294
+ earliest = offer.date;
295
+ }
296
+ }
297
+ return earliest;
298
+ }
299
+ function readCheckpoints(value) {
300
+ if (!value || typeof value !== 'object' || Array.isArray(value))
301
+ return {};
302
+ const out = {};
303
+ for (const [machineId, bytes] of Object.entries(value)) {
304
+ if (typeof bytes === 'number' && Number.isFinite(bytes) && bytes >= 0)
305
+ out[machineId] = bytes;
306
+ }
307
+ return out;
308
+ }
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.
172
313
  */
173
- function readUsageStore(storePath = resolveUsageStorePath()) {
314
+ function readUsageSnapshot(storePath = resolveUsageStorePath()) {
174
315
  try {
175
316
  if (!(0, fs_1.existsSync)(storePath))
176
317
  return emptyStore();
@@ -190,15 +331,120 @@ function readUsageStore(storePath = resolveUsageStorePath()) {
190
331
  if (isUsableRecord(value))
191
332
  entries[key] = value;
192
333
  }
193
- return { version: parsed.version ?? STORE_VERSION, updatedAt: parsed.updatedAt ?? emptyStore().updatedAt, entries };
334
+ return {
335
+ version: parsed.version ?? STORE_VERSION,
336
+ updatedAt: parsed.updatedAt ?? emptyStore().updatedAt,
337
+ entries,
338
+ foldedThroughBytes: readCheckpoints(parsed.foldedThroughBytes),
339
+ offersEraStart: typeof parsed.offersEraStart === 'string'
340
+ ? parsed.offersEraStart
341
+ : deriveOffersEraStart(entries),
342
+ };
194
343
  }
195
344
  catch {
196
345
  return emptyStore();
197
346
  }
198
347
  }
199
- function writeUsageStore(store, storePath = resolveUsageStorePath()) {
200
- (0, fs_1.mkdirSync)((0, path_1.dirname)(storePath), { recursive: true });
201
- (0, fs_1.writeFileSync)(storePath, `${JSON.stringify(store, null, 2)}\n`, 'utf8');
348
+ /**
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.
362
+ */
363
+ function writeUsageSnapshot(store, storePath = resolveUsageStorePath()) {
364
+ try {
365
+ (0, fs_1.mkdirSync)((0, path_1.dirname)(storePath), { recursive: true });
366
+ (0, fs_1.writeFileSync)(storePath, `${JSON.stringify(store, null, 2)}\n`, 'utf8');
367
+ return true;
368
+ }
369
+ catch {
370
+ return false;
371
+ }
372
+ }
373
+ /**
374
+ * Fold an event log into a base record set.
375
+ *
376
+ * `base` defaults to empty, which is a cold full fold. `readUsageStore` passes the
377
+ * snapshot and its checkpoints, which is the warm path S4b measured flat at
378
+ * 2.6 ms from 9.9 MB to 79 MB of log.
379
+ */
380
+ function foldUsageEvents(options) {
381
+ const limits = options.limits ?? defaultUsageLimits();
382
+ const now = options.now ?? new Date();
383
+ const base = options.base ?? emptyStore();
384
+ const tail = (0, learning_usage_events_1.readEventsSince)(options.eventDir, options.checkpoints ?? {});
385
+ // A checkpoint past the end of its log means the log was compacted or replaced
386
+ // under this snapshot, so the snapshot may already count events the log no
387
+ // longer holds. Re-folding onto it would double count. Discard and fold cold.
388
+ if (tail.restarted) {
389
+ const cold = (0, learning_usage_events_1.readEventsSince)(options.eventDir, {});
390
+ return applyTail(emptyStore(), cold, limits, now, true);
391
+ }
392
+ return applyTail(base, tail, limits, now, false);
393
+ }
394
+ function applyTail(base, tail, limits, now, restarted) {
395
+ const store = {
396
+ version: base.version,
397
+ updatedAt: base.updatedAt,
398
+ entries: base.entries,
399
+ foldedThroughBytes: { ...(base.foldedThroughBytes ?? {}) },
400
+ offersEraStart: base.offersEraStart ?? null,
401
+ };
402
+ let tailBytes = 0;
403
+ const touched = new Set();
404
+ for (const read of tail.reads) {
405
+ const from = restarted ? 0 : (store.foldedThroughBytes[read.machineId] ?? 0);
406
+ tailBytes += Math.max(0, read.through - from);
407
+ for (const event of read.records) {
408
+ const key = applyEvent(store, event, limits, now);
409
+ if (key)
410
+ touched.add(key);
411
+ }
412
+ store.foldedThroughBytes[read.machineId] = read.through;
413
+ }
414
+ for (const key of touched) {
415
+ const record = store.entries[key];
416
+ if (record)
417
+ trimLogs(record, limits, now);
418
+ }
419
+ // E: the invariant is evaluated on the read path, where it is always current
420
+ // and costs the write path nothing.
421
+ flagIntegrity(store);
422
+ return { store, skippedLines: tail.skippedLines, restarted, tailBytes, logs: tail.reads };
423
+ }
424
+ /**
425
+ * The checkpointed read every consumer uses. Snapshot plus the bytes appended
426
+ * past it, republished once the tail is worth re-checkpointing.
427
+ */
428
+ function readUsageStore(storePath = resolveUsageStorePath(), options = {}) {
429
+ const snapshot = readUsageSnapshot(storePath);
430
+ 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({
434
+ eventDir,
435
+ limits: options.limits,
436
+ now: options.now,
437
+ base: snapshot,
438
+ checkpoints: snapshot.foldedThroughBytes ?? {},
439
+ });
440
+ const mode = options.refreshSnapshot ?? 'auto';
441
+ const shouldRefresh = mode === 'always'
442
+ || (mode === 'auto' && (folded.tailBytes >= SNAPSHOT_REFRESH_BYTES || !(0, fs_1.existsSync)(storePath) || folded.restarted));
443
+ if (shouldRefresh) {
444
+ folded.store.updatedAt = (options.now ?? new Date()).toISOString();
445
+ writeUsageSnapshot(folded.store, storePath);
446
+ }
447
+ return folded.store;
202
448
  }
203
449
  // ── Mutation helpers ─────────────────────────────────────────────────────────
204
450
  function emptyOutcomeCounts() {
@@ -277,67 +523,288 @@ function withinRetention(date, retentionDays, now) {
277
523
  return true;
278
524
  return (now.getTime() - parsed) / 86_400_000 <= retentionDays;
279
525
  }
526
+ const byDate = (a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0);
527
+ function isAmbientJob(job) {
528
+ return exports.AMBIENT_JOB_LABELS.includes(job ?? '');
529
+ }
530
+ /**
531
+ * Issue #1752 D3: bound the offer log without letting session traffic evict the
532
+ * only records that can answer R7.
533
+ *
534
+ * Measured cause: `fraim_connect` records one offer per entry per session under
535
+ * the label `session`, and a plain newest-50 rule then evicted every
536
+ * job-labelled record within about a day. 2067 of the 2354 retained offer records
537
+ * in the live store carried `session` or `__freeform__`, and only three real job
538
+ * names appeared at all, which is why `classify --job <real-job>` could almost
539
+ * never find a match.
540
+ *
541
+ * Job-labelled records get the first `jobOfferReserve` slots; the rest of the
542
+ * budget goes to the newest of everything else.
543
+ */
544
+ function selectRetainedOffers(log, limits) {
545
+ if (log.length <= limits.offerLogLimit)
546
+ return log;
547
+ const jobLabelled = log.filter((r) => !isAmbientJob(r.job));
548
+ const ambient = log.filter((r) => isAmbientJob(r.job));
549
+ const reserve = Math.min(limits.jobOfferReserve, limits.offerLogLimit);
550
+ const guaranteed = jobLabelled.slice(-Math.min(jobLabelled.length, reserve));
551
+ const remaining = limits.offerLogLimit - guaranteed.length;
552
+ const pool = [...jobLabelled.slice(0, jobLabelled.length - guaranteed.length), ...ambient].sort(byDate);
553
+ const filler = remaining > 0 ? pool.slice(-remaining) : [];
554
+ return [...guaranteed, ...filler].sort(byDate).slice(-limits.offerLogLimit);
555
+ }
280
556
  /**
281
557
  * Trim one entry's logs: drop records outside the retention window, then keep the
282
558
  * newest N. Aggregates are never touched, so R16a's second criterion holds.
283
559
  */
284
560
  function trimLogs(record, limits, now) {
285
- const byDate = (a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0);
286
- record.offered.log = record.offered.log
287
- .filter((r) => withinRetention(r.date, limits.retentionDays, now))
288
- .sort(byDate)
289
- .slice(-limits.offerLogLimit);
561
+ record.offered.log = selectRetainedOffers(record.offered.log.filter((r) => withinRetention(r.date, limits.retentionDays, now)).sort(byDate), limits);
290
562
  record.fired.log = record.fired.log
291
563
  .filter((r) => withinRetention(r.date, limits.retentionDays, now))
292
564
  .sort(byDate)
293
565
  .slice(-limits.firingLogLimit);
294
566
  }
567
+ function offerEvent(offer) {
568
+ return {
569
+ kind: 'offer',
570
+ key: offer.key,
571
+ family: offer.family,
572
+ title: offer.title,
573
+ fileType: offer.fileType,
574
+ level: offer.level,
575
+ aboveThreshold: Boolean(offer.aboveThreshold),
576
+ job: offer.job,
577
+ date: offer.date,
578
+ agent: offer.agent ?? null,
579
+ model: normalizeModelName(offer.model),
580
+ };
581
+ }
582
+ function firingEvent(firing) {
583
+ return {
584
+ kind: 'firing',
585
+ key: firing.key,
586
+ family: firing.family,
587
+ title: firing.title,
588
+ fileType: firing.fileType,
589
+ level: firing.level,
590
+ outcome: firing.outcome,
591
+ note: sanitizeNote(firing.note),
592
+ job: firing.job,
593
+ date: firing.date,
594
+ agent: firing.agent ?? null,
595
+ model: normalizeModelName(firing.model),
596
+ source: firing.source ?? 'attested',
597
+ sourceRef: firing.sourceRef,
598
+ };
599
+ }
600
+ /**
601
+ * Apply one logged event to the record set. Returns the key it touched, or null
602
+ * when the event is unusable or already counted.
603
+ *
604
+ * Firing idempotency lives here rather than on the write path, so it holds
605
+ * however many times an event was appended: `(key, sourceRef)` is the complete
606
+ * identity of an attestation, which is the same fact the migration's exact firing
607
+ * recovery rests on.
608
+ */
609
+ function applyEvent(store, event, limits, now) {
610
+ const kind = event.kind;
611
+ const key = typeof event.key === 'string' ? event.key : '';
612
+ const date = typeof event.date === 'string' ? event.date : '';
613
+ if (!key || !isValidDate(date))
614
+ return null;
615
+ const family = (event.family === 'rule' ? 'rule' : 'learning');
616
+ const fileType = typeof event.fileType === 'string' ? event.fileType : undefined;
617
+ const level = typeof event.level === 'string' ? event.level : undefined;
618
+ const model = normalizeModelName(event.model);
619
+ const job = typeof event.job === 'string' ? event.job : 'unknown';
620
+ if (kind === 'offer')
621
+ return applyOfferEvent(store, event, { key, date, family, fileType, level, model, job });
622
+ if (kind === 'firing')
623
+ return applyFiringEvent(store, event, { key, date, family, fileType, level, model, job });
624
+ // An event kind this version does not know is skipped, so a log written by a
625
+ // newer version degrades to what this one understands rather than failing.
626
+ void limits;
627
+ void now;
628
+ return null;
629
+ }
630
+ function applyOfferEvent(store, event, common) {
631
+ const record = ensureRecord(store, common.key, common.family, common.fileType, common.level);
632
+ rememberTitle(record, typeof event.title === 'string' ? event.title : undefined);
633
+ record.offered.total += 1;
634
+ if (event.aboveThreshold)
635
+ record.offered.aboveThreshold += 1;
636
+ if (common.model)
637
+ record.offered.byModel[common.model] = (record.offered.byModel[common.model] ?? 0) + 1;
638
+ record.offered.lastOfferedAt = maxDate(record.offered.lastOfferedAt, common.date);
639
+ record.offered.log.push({
640
+ date: common.date,
641
+ job: common.job,
642
+ agent: event.agent ?? null,
643
+ model: common.model,
644
+ aboveThreshold: Boolean(event.aboveThreshold),
645
+ });
646
+ // The era only ever moves earlier, so the scoped invariant's boundary is
647
+ // monotonic and a late-syncing log cannot shrink the window it applies to.
648
+ store.offersEraStart = minDate(store.offersEraStart, common.date);
649
+ return common.key;
650
+ }
651
+ function applyFiringEvent(store, event, common) {
652
+ const outcome = event.outcome;
653
+ if (!exports.FIRING_OUTCOMES.includes(outcome))
654
+ return null;
655
+ const record = ensureRecord(store, common.key, common.family, common.fileType, common.level);
656
+ // Idempotency lives here rather than on the write path, so it holds however
657
+ // many times an event was appended. `(key, sourceRef)` is the complete identity
658
+ // of an attestation, which is the same fact the migration's exact firing
659
+ // recovery rests on.
660
+ const sourceRef = typeof event.sourceRef === 'string' ? event.sourceRef : '';
661
+ if (sourceRef && record.fired.seenSources.includes(sourceRef))
662
+ return null;
663
+ rememberTitle(record, typeof event.title === 'string' ? event.title : undefined);
664
+ record.fired.total += 1;
665
+ record.fired.byOutcome[outcome] = (record.fired.byOutcome[outcome] ?? 0) + 1;
666
+ record.fired.lastAttestedAt = maxDate(record.fired.lastAttestedAt, common.date);
667
+ if (exports.CLOCK_ADVANCING_OUTCOMES.includes(outcome)) {
668
+ record.fired.firstFiredAt = minDate(record.fired.firstFiredAt, common.date);
669
+ record.fired.lastFiredAt = maxDate(record.fired.lastFiredAt, common.date);
670
+ if (common.model)
671
+ record.fired.byModel[common.model] = (record.fired.byModel[common.model] ?? 0) + 1;
672
+ }
673
+ record.fired.log.push({
674
+ date: common.date,
675
+ outcome,
676
+ job: common.job,
677
+ agent: event.agent ?? null,
678
+ model: common.model,
679
+ note: sanitizeNote(String(event.note ?? '')),
680
+ source: event.source === 'backfill-legacy' ? 'backfill-legacy' : 'attested',
681
+ });
682
+ if (sourceRef)
683
+ record.fired.seenSources.push(sourceRef);
684
+ return common.key;
685
+ }
686
+ // ── The scoped integrity invariant (issue #1752 E) ──────────────────────────
687
+ /**
688
+ * How many of this entry's attested firings fall inside the offers era.
689
+ *
690
+ * The invariant the issue asks for, `fired <= offered`, is unsound as literally
691
+ * stated: `runBackfill` writes firings attested by 299 retrospectives that
692
+ * predate offer recording entirely, and those have no offer by construction.
693
+ * Scoped to attested firings dated at or after the store's first recorded offer,
694
+ * it holds and means something.
695
+ *
696
+ * Two bases, because the check has two jobs with opposite failure budgets.
697
+ *
698
+ * 'log' Counts only the firings the bounded log still holds. It cannot
699
+ * over-count, so it can never refuse a correct write. This is what
700
+ * the CLI throws on.
701
+ * 'aggregate' Starts from `fired.total` and subtracts the firings the log
702
+ * shows are exempt. It sees an entry whose detail has aged out,
703
+ * which is the shape all 58 violating entries in the live store
704
+ * have, and it can over-count when the log no longer shows the
705
+ * exemptions. That is acceptable for a report and not for a
706
+ * refusal, so this is what the read path flags with and `verify`
707
+ * lists.
708
+ */
709
+ function attestedFiringsInOffersEra(record, offersEraStart, basis) {
710
+ if (!offersEraStart)
711
+ return 0;
712
+ const inLog = record.fired.log.filter((r) => r.source === 'attested' && r.date >= offersEraStart).length;
713
+ if (basis === 'log')
714
+ return inLog;
715
+ const exemptInLog = record.fired.log.filter((r) => r.source === 'backfill-legacy' || r.date < offersEraStart).length;
716
+ return Math.max(inLog, record.fired.total - exemptInLog);
717
+ }
718
+ function integrityViolation(record, offersEraStart, basis = 'aggregate') {
719
+ // An entry with no offer record at all is not an accounting overflow; it is the
720
+ // question defect 2 is about, and it has several innocent causes: the offers
721
+ // were recorded under the entry's other key before #1752 A2 merged them, the
722
+ // entry is a rule or a demoted lesson the offer path does not enumerate, or the
723
+ // offers are sitting in another machine's event log that has not synced yet.
724
+ // Throwing on any of those would block a correct attestation on a sync
725
+ // artefact. The overflow this invariant exists to catch is the one the issue
726
+ // measured: entries showing `offered: 1, fired: 10`, which all have an offer.
727
+ if (record.offered.total === 0)
728
+ return null;
729
+ const inEra = attestedFiringsInOffersEra(record, offersEraStart, basis);
730
+ if (inEra <= record.offered.total)
731
+ return null;
732
+ return {
733
+ flag: 'fired-exceeds-offered',
734
+ count: inEra - record.offered.total,
735
+ detail: `${inEra} attested firings dated on or after ${offersEraStart} against ${record.offered.total} recorded offers. An entry cannot fire without being offered, so one of the two counters is wrong for this entry.`,
736
+ };
737
+ }
738
+ function flagIntegrity(store) {
739
+ for (const record of Object.values(store.entries)) {
740
+ const violation = integrityViolation(record, store.offersEraStart);
741
+ if (violation)
742
+ record.integrity = violation;
743
+ else if (record.integrity)
744
+ delete record.integrity;
745
+ }
746
+ }
747
+ /**
748
+ * Materialize the snapshot when there is none.
749
+ *
750
+ * The snapshot is derived and normally published by readers, but a workspace that
751
+ * has only ever been written to would have no snapshot at all, so every reader
752
+ * would pay a cold fold and anything inspecting the store as a file would find
753
+ * nothing there. Bounded: it only runs when the file is absent, and at that point
754
+ * the fold is over a log that has just been created. A failure is swallowed, like
755
+ * every other snapshot write.
756
+ */
757
+ function ensureSnapshotExists(storePath, eventDir, limits, now) {
758
+ try {
759
+ if ((0, fs_1.existsSync)(storePath))
760
+ return;
761
+ readUsageStore(storePath, { limits, now, eventDir, refreshSnapshot: 'always' });
762
+ }
763
+ catch {
764
+ // The log is authoritative. A missing snapshot costs a cold fold, nothing else.
765
+ }
766
+ }
295
767
  /**
296
768
  * R1 and R2: write one offer record per listed entry, without any agent
297
769
  * involvement and without depending on the agent reading the file.
770
+ *
771
+ * Issue #1752 B: this appends and does not read. The read-modify-write it
772
+ * replaces lost 1589 of 1600 writes under eight concurrent processes on a plain
773
+ * local disk (spike S1b), which is the dominant loss path behind both the
774
+ * impossible counters and the four conflict copies. Nothing here can throw on an
775
+ * integrity violation either: R2 requires that a usage failure never changes what
776
+ * the agent is given, so the violation is flagged by the read path instead.
298
777
  */
299
778
  function recordOffers(offers, options) {
300
779
  const storePath = options.storePath ?? resolveUsageStorePath();
301
- const now = options.now ?? new Date();
302
- const store = readUsageStore(storePath);
303
- let recorded = 0;
304
- for (const offer of offers) {
305
- if (!offer.key || !isValidDate(offer.date))
306
- continue;
307
- const record = ensureRecord(store, offer.key, offer.family, offer.fileType, offer.level);
308
- rememberTitle(record, offer.title);
309
- record.offered.total += 1;
310
- if (offer.aboveThreshold)
311
- record.offered.aboveThreshold += 1;
312
- if (offer.model)
313
- record.offered.byModel[offer.model] = (record.offered.byModel[offer.model] ?? 0) + 1;
314
- record.offered.lastOfferedAt = maxDate(record.offered.lastOfferedAt, offer.date);
315
- record.offered.log.push({
316
- date: offer.date,
317
- job: offer.job,
318
- agent: offer.agent ?? null,
319
- model: offer.model ?? null,
320
- aboveThreshold: Boolean(offer.aboveThreshold),
321
- });
322
- trimLogs(record, options.limits, now);
323
- recorded += 1;
324
- }
325
- if (recorded > 0) {
326
- store.updatedAt = now.toISOString();
327
- writeUsageStore(store, storePath);
328
- }
329
- return { recorded, entries: Object.keys(store.entries).length };
780
+ const eventDir = options.eventDir ?? (0, learning_usage_events_1.resolveEventDir)(storePath);
781
+ const usable = offers.filter((offer) => offer.key && isValidDate(offer.date));
782
+ if (usable.length === 0)
783
+ return { recorded: 0, entries: 0 };
784
+ (0, learning_usage_events_1.appendEvents)(eventDir, usable.map(offerEvent), options.machineId);
785
+ ensureSnapshotExists(storePath, eventDir, options.limits, options.now ?? new Date());
786
+ return { recorded: usable.length, entries: new Set(usable.map((o) => o.key)).size };
330
787
  }
331
788
  /**
332
789
  * R4 and R6: write the attested firings. Idempotent per (entry, attestation
333
790
  * source) so a re-run, or a backfill that overlaps a live attestation, cannot
334
791
  * inflate a count.
792
+ *
793
+ * Unlike the offer path this reads first, because it has two questions only the
794
+ * current record can answer: whether this attestation has already contributed,
795
+ * and whether the scoped invariant is about to be violated. It still writes by
796
+ * appending, and the fold applies the same `(key, sourceRef)` idempotency, so a
797
+ * racing duplicate append is folded once rather than counted twice.
798
+ *
799
+ * The invariant throws here. This is a CLI path with an operator watching, and a
800
+ * violation means a real accounting bug rather than a cosmetic one.
335
801
  */
336
802
  function recordFirings(firings, options) {
337
803
  const storePath = options.storePath ?? resolveUsageStorePath();
804
+ const eventDir = options.eventDir ?? (0, learning_usage_events_1.resolveEventDir)(storePath);
338
805
  const now = options.now ?? new Date();
339
- const store = readUsageStore(storePath);
340
- let recorded = 0;
806
+ const store = readUsageStore(storePath, { limits: options.limits, now, eventDir });
807
+ const events = [];
341
808
  let alreadyRecorded = 0;
342
809
  for (const firing of firings) {
343
810
  if (!exports.FIRING_OUTCOMES.includes(firing.outcome)) {
@@ -345,40 +812,51 @@ function recordFirings(firings, options) {
345
812
  }
346
813
  if (!firing.key || !isValidDate(firing.date))
347
814
  continue;
348
- const record = ensureRecord(store, firing.key, firing.family, firing.fileType, firing.level);
349
- if (firing.sourceRef && record.fired.seenSources.includes(firing.sourceRef)) {
815
+ const existing = store.entries[firing.key];
816
+ if (firing.sourceRef && existing?.fired.seenSources.includes(firing.sourceRef)) {
350
817
  alreadyRecorded += 1;
351
818
  continue;
352
819
  }
353
- rememberTitle(record, firing.title);
354
- record.fired.total += 1;
355
- record.fired.byOutcome[firing.outcome] += 1;
356
- record.fired.lastAttestedAt = maxDate(record.fired.lastAttestedAt, firing.date);
357
- if (exports.CLOCK_ADVANCING_OUTCOMES.includes(firing.outcome)) {
358
- record.fired.firstFiredAt = minDate(record.fired.firstFiredAt, firing.date);
359
- record.fired.lastFiredAt = maxDate(record.fired.lastFiredAt, firing.date);
360
- if (firing.model)
361
- record.fired.byModel[firing.model] = (record.fired.byModel[firing.model] ?? 0) + 1;
820
+ events.push(firingEvent(firing));
821
+ }
822
+ if (events.length > 0) {
823
+ if ((options.integrityMode ?? 'throw') === 'throw') {
824
+ assertScopedIntegrity(store, events, options.limits, now);
362
825
  }
363
- record.fired.log.push({
364
- date: firing.date,
365
- outcome: firing.outcome,
366
- job: firing.job,
367
- agent: firing.agent ?? null,
368
- model: firing.model ?? null,
369
- note: sanitizeNote(firing.note),
370
- source: firing.source ?? 'attested',
371
- });
372
- if (firing.sourceRef)
373
- record.fired.seenSources.push(firing.sourceRef);
374
- trimLogs(record, options.limits, now);
375
- recorded += 1;
826
+ (0, learning_usage_events_1.appendEvents)(eventDir, events, options.machineId);
827
+ // Unlike the offer path, this republishes the snapshot. `record-firings` runs
828
+ // from the CLI once per retrospective, so the fold is cheap, and it makes the
829
+ // store file current the moment an operator's write returns rather than at
830
+ // whatever point a later reader happens to refresh it. The offer path stays
831
+ // append-only, because that is the one that runs on every `fraim_connect`.
832
+ readUsageStore(storePath, { limits: options.limits, now, eventDir, refreshSnapshot: 'always' });
376
833
  }
377
- if (recorded > 0 || alreadyRecorded > 0) {
378
- store.updatedAt = now.toISOString();
379
- writeUsageStore(store, storePath);
834
+ return { recorded: events.length, alreadyRecorded };
835
+ }
836
+ /**
837
+ * E, CLI form: refuse to write a batch that would put an entry into the
838
+ * impossible state the issue reports on 58 of its 223 entries.
839
+ */
840
+ function assertScopedIntegrity(store, events, limits, now) {
841
+ const projected = {
842
+ version: store.version,
843
+ updatedAt: store.updatedAt,
844
+ entries: JSON.parse(JSON.stringify(store.entries)),
845
+ foldedThroughBytes: store.foldedThroughBytes,
846
+ offersEraStart: store.offersEraStart,
847
+ };
848
+ for (const event of events)
849
+ applyEvent(projected, event, limits, now);
850
+ for (const event of events) {
851
+ const record = projected.entries[event.key];
852
+ if (!record)
853
+ continue;
854
+ // 'log' basis: a refusal must never rest on a number that can over-count.
855
+ const violation = integrityViolation(record, projected.offersEraStart, 'log');
856
+ if (violation) {
857
+ throw new Error(`Refusing to record firings for ${event.key}: ${violation.detail} Run \`learning-usage verify\` for the full integrity picture before forcing this through.`);
858
+ }
380
859
  }
381
- return { recorded, alreadyRecorded };
382
860
  }
383
861
  /**
384
862
  * R5: record that an entry's title changed, so a firing written before the edit
@@ -387,19 +865,28 @@ function recordFirings(firings, options) {
387
865
  */
388
866
  function noteTitleChange(key, newTitle, options) {
389
867
  const storePath = options.storePath ?? resolveUsageStorePath();
390
- const store = readUsageStore(storePath);
868
+ const eventDir = options.eventDir ?? (0, learning_usage_events_1.resolveEventDir)(storePath);
869
+ const store = readUsageStore(storePath, { limits: options.limits, now: options.now, eventDir });
391
870
  const record = store.entries[key];
392
871
  if (!record)
393
872
  return;
394
873
  rememberTitle(record, newTitle);
395
874
  store.updatedAt = (options.now ?? new Date()).toISOString();
396
- writeUsageStore(store, storePath);
875
+ writeUsageSnapshot(store, storePath);
397
876
  }
398
- /** Apply retention and log bounds across the whole store. Reports what it dropped. */
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
+ */
399
885
  function pruneUsageStore(limits, options = {}) {
400
886
  const storePath = options.storePath ?? resolveUsageStorePath();
887
+ const eventDir = options.eventDir ?? (0, learning_usage_events_1.resolveEventDir)(storePath);
401
888
  const now = options.now ?? new Date();
402
- const store = readUsageStore(storePath);
889
+ const store = readUsageStore(storePath, { limits, now, eventDir });
403
890
  let prunedOfferRecords = 0;
404
891
  let prunedFiringRecords = 0;
405
892
  for (const record of Object.values(store.entries)) {
@@ -409,9 +896,29 @@ function pruneUsageStore(limits, options = {}) {
409
896
  prunedOfferRecords += offersBefore - record.offered.log.length;
410
897
  prunedFiringRecords += firingsBefore - record.fired.log.length;
411
898
  }
412
- if (prunedOfferRecords > 0 || prunedFiringRecords > 0) {
413
- store.updatedAt = now.toISOString();
414
- writeUsageStore(store, storePath);
415
- }
416
- return { prunedOfferRecords, prunedFiringRecords, entries: Object.keys(store.entries).length };
899
+ const compaction = compactUsageEvents({ eventDir, snapshotPath: storePath, limits, now, store });
900
+ return { prunedOfferRecords, prunedFiringRecords, entries: Object.keys(store.entries).length, compaction };
901
+ }
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.
909
+ */
910
+ function compactUsageEvents(options) {
911
+ const now = options.now ?? new Date();
912
+ const store = options.store
913
+ ?? readUsageStore(options.snapshotPath, { limits: options.limits, now, eventDir: options.eventDir });
914
+ const result = (0, learning_usage_events_1.compactEventLogs)(options.eventDir, (event) => {
915
+ const date = typeof event.date === 'string' ? event.date : '';
916
+ if (!isValidDate(date))
917
+ 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;
417
924
  }