@ngockhoale/ukit 3.0.6 → 3.0.7

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.
Files changed (39) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/package.json +1 -1
  3. package/scripts/bench/data-foundation.mjs +562 -0
  4. package/src/core/observability/adapters/common.js +75 -0
  5. package/src/core/observability/adapters/contextAdapter.js +55 -0
  6. package/src/core/observability/adapters/decisionAdapter.js +61 -0
  7. package/src/core/observability/adapters/routeAdapter.js +135 -0
  8. package/src/core/observability/analytics/digest.js +186 -0
  9. package/src/core/observability/analytics/fingerprints.js +126 -0
  10. package/src/core/observability/analytics/opportunities.js +329 -0
  11. package/src/core/observability/analytics/rebuild.js +56 -0
  12. package/src/core/observability/analytics/summary.js +298 -0
  13. package/src/core/observability/emit/config.js +29 -0
  14. package/src/core/observability/emit/recorder.js +297 -0
  15. package/src/core/observability/evaluation/aiPacket.js +230 -0
  16. package/src/core/observability/evaluation/optimizationKnowledge.js +172 -0
  17. package/src/core/observability/evaluation/replay.js +143 -0
  18. package/src/core/observability/evaluation/scorecard.js +445 -0
  19. package/src/core/observability/privacy/allowlist.js +185 -0
  20. package/src/core/observability/privacy/redaction.js +113 -0
  21. package/src/core/observability/privacy/sanitizeForSupport.js +133 -0
  22. package/src/core/observability/privacy/sanitizeObserved.js +134 -0
  23. package/src/core/observability/rollout.js +155 -0
  24. package/src/core/observability/schema/constants.js +66 -0
  25. package/src/core/observability/schema/registry.js +223 -0
  26. package/src/core/observability/schema/validate.js +227 -0
  27. package/src/core/observability/segments/internal.js +241 -0
  28. package/src/core/observability/segments/readSegments.js +215 -0
  29. package/src/core/observability/segments/recovery.js +123 -0
  30. package/src/core/observability/segments/retention.js +381 -0
  31. package/src/core/observability/support/import.js +402 -0
  32. package/src/core/observability/support/manifest.js +135 -0
  33. package/src/core/observability/support/paths.js +94 -0
  34. package/src/core/observability/support/projector.js +483 -0
  35. package/src/core/observability/support/renderer.js +130 -0
  36. package/src/core/observability/support/retention.js +155 -0
  37. package/template_project/.omp/RULES.md +6 -6
  38. package/template_project/.omp/config.yml +6 -0
  39. package/template_project/instructions/overlays/omp-rules.md +6 -6
@@ -0,0 +1,381 @@
1
+ /**
2
+ * retention.js (TASK-006, SPEC §5 DF-FR05 / §8)
3
+ *
4
+ * Write path, rotation, and retention for the append-only JSONL segment
5
+ * store. Layout under `root`:
6
+ *
7
+ * active.jsonl shared append target (O_APPEND — concurrent
8
+ * writers interleave whole rows, never bytes)
9
+ * seg-<seq>-<boot>-<writer>.jsonl sealed segment
10
+ * seg-<...>.meta.json sidecar: checksum, record/seq/importance
11
+ * stats, sealed_at_ms, recovered flag
12
+ * quarantine/ corrupt sealed segments (never falsified)
13
+ * _state.json next_segment_seq, expired/quarantined
14
+ * counters, disabled flag, active_since_ms
15
+ *
16
+ * Durability contract: appends are buffered writes — there is NO per-event
17
+ * fsync. A hard kill can lose the unflushed tail (bounded by the OS page
18
+ * cache, typically the last few KB); recoverTail() drops the partial final
19
+ * row and preserves the complete prefix. Never claim zero critical drops
20
+ * without fault evidence.
21
+ *
22
+ * Ordering contract: records are ordered by (boot_id, writer_id, sequence)
23
+ * and sealed segments by sealed_seq — never by wall clock, so clock jumps
24
+ * cannot reorder stored facts.
25
+ *
26
+ * Public surface (consumed by TASK-007 recorder):
27
+ * appendRecord(root, record, policy?) → { ok: true, written: true }
28
+ * | { ok: false, reason, errors? }
29
+ * rotateIfNeeded(root, policy?) → { ok: true, rotated, name? }
30
+ * | { ok: false, reason }
31
+ * enforceRetention(root, policy?) → { ok: true, expired, disabled }
32
+ * | { ok: false, reason }
33
+ *
34
+ * policy: { maxSegmentBytes, maxSegmentAgeMs, maxTotalBytes, ioTimeoutMs,
35
+ * now } — `now` is injectable for deterministic tests.
36
+ */
37
+
38
+ import fs from 'node:fs';
39
+ import path from 'node:path';
40
+
41
+ import { validateSemanticRecord } from '../schema/validate.js';
42
+ import { IMPORTANCE_LEVELS } from '../schema/constants.js';
43
+ import {
44
+ ACTIVE_FILE,
45
+ DEFAULT_IO_TIMEOUT_MS,
46
+ ioReason,
47
+ ioTimeoutMsOf,
48
+ listSegments,
49
+ metaPathFor,
50
+ nowOf,
51
+ policyOf,
52
+ readState,
53
+ resolveRoot,
54
+ sanitizeNameComponent,
55
+ sha256Buffer,
56
+ totalSegmentBytes,
57
+ withTimeout,
58
+ writeState,
59
+ } from './internal.js';
60
+
61
+ function importanceRank(level) {
62
+ const idx = IMPORTANCE_LEVELS.indexOf(level);
63
+ return idx >= 0 ? idx : 0; // unknown → lowest → evicted first under cap
64
+ }
65
+
66
+ /**
67
+ * Seal the current active segment: parse stats, checksum the exact bytes,
68
+ * move it aside via link+unlink (atomic, never overwrites an existing seal),
69
+ * and write its meta sidecar. Concurrent rotators lose the race on ENOENT
70
+ * and simply report not-sealed.
71
+ */
72
+ async function sealActive(root, state, policy) {
73
+ const timeoutMs = ioTimeoutMsOf(policy);
74
+ const activePath = path.join(root, ACTIVE_FILE);
75
+
76
+ let lst;
77
+ try {
78
+ lst = await withTimeout(fs.promises.lstat(activePath), timeoutMs);
79
+ } catch (err) {
80
+ if (err && err.code === 'ENOENT') return { ok: true, sealed: false };
81
+ return { ok: false, reason: ioReason(err) };
82
+ }
83
+ if (lst.isSymbolicLink()) return { ok: false, reason: 'unsafe_path' };
84
+ if (!lst.isFile() || lst.size === 0) {
85
+ state.active_since_ms = null;
86
+ await writeState(root, state, timeoutMs);
87
+ return { ok: true, sealed: false };
88
+ }
89
+
90
+ let buf;
91
+ try {
92
+ buf = await withTimeout(fs.promises.readFile(activePath), timeoutMs);
93
+ } catch (err) {
94
+ return { ok: false, reason: ioReason(err) };
95
+ }
96
+
97
+ // Stats from the sealed bytes themselves — never trusted from memory.
98
+ let recordCount = 0;
99
+ let corruptLines = 0;
100
+ let seqMin = null;
101
+ let seqMax = null;
102
+ let importanceMax = 'low';
103
+ let maxRank = -1;
104
+ let bootId = 'unknown';
105
+ let writerId = 'unknown';
106
+ const text = buf.toString('utf8');
107
+ const complete = text.endsWith('\n') ? text.slice(0, -1) : text;
108
+ for (const line of complete.split('\n')) {
109
+ if (line.length === 0) continue;
110
+ let parsed = null;
111
+ try {
112
+ parsed = JSON.parse(line);
113
+ } catch {
114
+ corruptLines += 1;
115
+ continue;
116
+ }
117
+ recordCount += 1;
118
+ if (bootId === 'unknown' && typeof parsed.boot_id === 'string') bootId = parsed.boot_id;
119
+ if (writerId === 'unknown' && typeof parsed.writer_id === 'string') writerId = parsed.writer_id;
120
+ if (Number.isInteger(parsed.sequence)) {
121
+ seqMin = seqMin === null ? parsed.sequence : Math.min(seqMin, parsed.sequence);
122
+ seqMax = seqMax === null ? parsed.sequence : Math.max(seqMax, parsed.sequence);
123
+ }
124
+ const rank = importanceRank(parsed.importance);
125
+ if (rank > maxRank) {
126
+ maxRank = rank;
127
+ importanceMax = parsed.importance;
128
+ }
129
+ }
130
+
131
+ // link+unlink: fails atomically if the target seal already exists (another
132
+ // writer won the seq) — rename would silently overwrite it.
133
+ let seq = state.next_segment_seq;
134
+ let name = null;
135
+ let linked = false;
136
+ for (let attempt = 0; attempt < 64; attempt++) {
137
+ name = `seg-${String(seq).padStart(6, '0')}-${sanitizeNameComponent(bootId)}-${sanitizeNameComponent(writerId)}.jsonl`;
138
+ const target = path.join(root, name);
139
+ try {
140
+ await withTimeout(fs.promises.link(activePath, target), timeoutMs);
141
+ await withTimeout(fs.promises.unlink(activePath), timeoutMs);
142
+ linked = true;
143
+ break;
144
+ } catch (err) {
145
+ if (err && err.code === 'ENOENT') return { ok: true, sealed: false }; // lost the race
146
+ if (err && err.code === 'EEXIST') {
147
+ seq += 1;
148
+ continue;
149
+ }
150
+ return { ok: false, reason: ioReason(err) };
151
+ }
152
+ }
153
+ // Every attempt collided — never write meta for a name that was not sealed.
154
+ if (!linked) return { ok: false, reason: 'seal_conflict' };
155
+
156
+ const meta = {
157
+ sealed_seq: seq,
158
+ boot_id: bootId,
159
+ writer_id: writerId,
160
+ sealed_at_ms: nowOf(policy),
161
+ record_count: recordCount,
162
+ seq_min: seqMin,
163
+ seq_max: seqMax,
164
+ importance_max: importanceMax,
165
+ corrupt_lines: corruptLines,
166
+ recovered: false,
167
+ checksum: sha256Buffer(buf),
168
+ };
169
+ try {
170
+ await withTimeout(
171
+ fs.promises.writeFile(metaPathFor(path.join(root, name)), JSON.stringify(meta, null, 2)),
172
+ timeoutMs,
173
+ );
174
+ } catch (err) {
175
+ return { ok: false, reason: ioReason(err) };
176
+ }
177
+
178
+ state.next_segment_seq = Math.max(state.next_segment_seq, seq + 1);
179
+ state.active_since_ms = null;
180
+ await writeState(root, state, timeoutMs);
181
+ return { ok: true, sealed: true, name };
182
+ }
183
+
184
+ /**
185
+ * Append one validated record to the active segment. Validates the envelope
186
+ * (records reaching this layer are already sanitizeObserved output — this is
187
+ * the integrity gate, not the privacy gate). Rotates BEFORE writing when the
188
+ * line would push the active segment past maxSegmentBytes, then enforces
189
+ * retention. When the hard cap cannot be satisfied even after eviction the
190
+ * store flips to `disabled` and every subsequent append returns
191
+ * { ok: false, reason: 'disabled' } — never unbounded growth.
192
+ */
193
+ export async function appendRecord(root, record, policy) {
194
+ const p = policyOf(policy);
195
+ const timeoutMs = ioTimeoutMsOf(p);
196
+
197
+ const validation = validateSemanticRecord(record);
198
+ if (!validation.ok) {
199
+ return { ok: false, reason: 'invalid_record', errors: validation.errors };
200
+ }
201
+ const line = JSON.stringify(record) + '\n';
202
+ const lineBytes = Buffer.byteLength(line, 'utf8');
203
+
204
+ const resolved = await resolveRoot(root, timeoutMs);
205
+ if (!resolved.ok) return { ok: false, reason: resolved.reason };
206
+ const realRoot = resolved.root;
207
+
208
+ try {
209
+ await withTimeout(fs.promises.mkdir(realRoot, { recursive: true }), timeoutMs);
210
+ } catch (err) {
211
+ return { ok: false, reason: ioReason(err) };
212
+ }
213
+
214
+ let state = await readState(realRoot, timeoutMs);
215
+ if (state.disabled) return { ok: false, reason: 'disabled' };
216
+
217
+ // Rotate before writing when this line would overflow the active segment.
218
+ // A symlinked active.jsonl is rejected outright — appending through it
219
+ // would write outside the storage root (same rule as rotateIfNeeded).
220
+ try {
221
+ const st = await withTimeout(fs.promises.lstat(path.join(realRoot, ACTIVE_FILE)), timeoutMs);
222
+ if (st.isSymbolicLink()) return { ok: false, reason: 'unsafe_path' };
223
+ if (st.isFile() && st.size > 0 && st.size + lineBytes > p.maxSegmentBytes) {
224
+ const sealed = await sealActive(realRoot, state, p);
225
+ if (!sealed.ok) return { ok: false, reason: sealed.reason };
226
+ state = await readState(realRoot, timeoutMs);
227
+ }
228
+ } catch (err) {
229
+ if (!(err && err.code === 'ENOENT')) return { ok: false, reason: ioReason(err) };
230
+ }
231
+
232
+ // O_NOFOLLOW closes the lstat→append race: if active.jsonl was swapped to
233
+ // a symlink in between, open fails with ELOOP instead of writing through.
234
+ const appendFlags =
235
+ fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_APPEND |
236
+ (fs.constants.O_NOFOLLOW || 0);
237
+ try {
238
+ await withTimeout(
239
+ fs.promises.appendFile(path.join(realRoot, ACTIVE_FILE), line, { flag: appendFlags }),
240
+ timeoutMs,
241
+ );
242
+ } catch (err) {
243
+ return { ok: false, reason: ioReason(err) };
244
+ }
245
+
246
+ if (state.active_since_ms === null || state.active_since_ms === undefined) {
247
+ state.active_since_ms = nowOf(p);
248
+ await writeState(realRoot, state, timeoutMs);
249
+ }
250
+
251
+ const retention = await enforceRetention(realRoot, p);
252
+ if (!retention.ok) return { ok: false, reason: retention.reason };
253
+ if (retention.disabled) return { ok: false, reason: 'disabled' };
254
+ return { ok: true, written: true };
255
+ }
256
+
257
+ /**
258
+ * Seal the active segment when it has reached the size threshold or its age
259
+ * exceeds maxSegmentAgeMs. Safe under concurrent callers — losers of the
260
+ * seal race observe ENOENT and report rotated:false.
261
+ */
262
+ export async function rotateIfNeeded(root, policy) {
263
+ const p = policyOf(policy);
264
+ const timeoutMs = ioTimeoutMsOf(p);
265
+ const resolved = await resolveRoot(root, timeoutMs);
266
+ if (!resolved.ok) return { ok: false, reason: resolved.reason };
267
+ if (!resolved.exists) return { ok: true, rotated: false };
268
+
269
+ const state = await readState(resolved.root, timeoutMs);
270
+ const activePath = path.join(resolved.root, ACTIVE_FILE);
271
+ let st;
272
+ try {
273
+ st = await withTimeout(fs.promises.lstat(activePath), timeoutMs);
274
+ } catch (err) {
275
+ if (err && err.code === 'ENOENT') return { ok: true, rotated: false };
276
+ return { ok: false, reason: ioReason(err) };
277
+ }
278
+ if (st.isSymbolicLink()) return { ok: false, reason: 'unsafe_path' };
279
+ if (!st.isFile() || st.size === 0) return { ok: true, rotated: false };
280
+
281
+ const oversized = st.size >= p.maxSegmentBytes;
282
+ const aged =
283
+ state.active_since_ms !== null &&
284
+ state.active_since_ms !== undefined &&
285
+ nowOf(p) - state.active_since_ms >= p.maxSegmentAgeMs;
286
+ if (!oversized && !aged) return { ok: true, rotated: false };
287
+
288
+ const sealed = await sealActive(resolved.root, state, p);
289
+ if (!sealed.ok) return { ok: false, reason: sealed.reason };
290
+ return { ok: true, rotated: sealed.sealed, name: sealed.name };
291
+ }
292
+
293
+ async function readMeta(segmentPath, timeoutMs) {
294
+ try {
295
+ const raw = await withTimeout(fs.promises.readFile(metaPathFor(segmentPath), 'utf8'), timeoutMs);
296
+ const meta = JSON.parse(raw);
297
+ return meta && typeof meta === 'object' ? meta : null;
298
+ } catch {
299
+ return null;
300
+ }
301
+ }
302
+
303
+ async function removeSegment(root, name, timeoutMs) {
304
+ try {
305
+ await withTimeout(fs.promises.unlink(path.join(root, name)), timeoutMs);
306
+ } catch {
307
+ // Already gone (concurrent eviction) — fine.
308
+ }
309
+ try {
310
+ await withTimeout(fs.promises.unlink(metaPathFor(path.join(root, name))), timeoutMs);
311
+ } catch {
312
+ // No sidecar — fine.
313
+ }
314
+ }
315
+
316
+ /**
317
+ * Retention = age + size + importance under a hard total cap.
318
+ * 1. Age: sealed segments older than maxSegmentAgeMs expire.
319
+ * 2. Cap: while live .jsonl bytes exceed maxTotalBytes, evict the lowest
320
+ * importance_max segment first (ties → lowest sealed_seq = oldest).
321
+ * 3. If the cap is still unhittable (e.g. the active segment alone exceeds
322
+ * it), the store reports disabled — bounded, never silent growth.
323
+ * The active segment is never evicted; only sealed facts expire.
324
+ */
325
+ export async function enforceRetention(root, policy) {
326
+ const p = policyOf(policy);
327
+ const timeoutMs = ioTimeoutMsOf(p);
328
+ const resolved = await resolveRoot(root, timeoutMs);
329
+ if (!resolved.ok) return { ok: false, reason: resolved.reason };
330
+ if (!resolved.exists) return { ok: true, expired: 0, disabled: false };
331
+
332
+ const state = await readState(resolved.root, timeoutMs);
333
+ const listing = await listSegments(resolved.root, timeoutMs);
334
+ if (!listing.ok) return { ok: false, reason: listing.reason };
335
+
336
+ const now = nowOf(p);
337
+ let expired = 0;
338
+ const live = [];
339
+
340
+ for (const seg of listing.sealed) {
341
+ const meta = await readMeta(seg.path, timeoutMs);
342
+ const sealedAt = meta && Number.isFinite(meta.sealed_at_ms) ? meta.sealed_at_ms : null;
343
+ let ageBasis = sealedAt;
344
+ if (ageBasis === null) {
345
+ try {
346
+ ageBasis = (await withTimeout(fs.promises.stat(seg.path), timeoutMs)).mtimeMs;
347
+ } catch {
348
+ ageBasis = now; // unknown age → treat as fresh, never evict blindly
349
+ }
350
+ }
351
+ if (now - ageBasis >= p.maxSegmentAgeMs) {
352
+ await removeSegment(resolved.root, seg.name, timeoutMs);
353
+ expired += 1;
354
+ } else {
355
+ live.push({
356
+ name: seg.name,
357
+ seq: seg.seq,
358
+ size: seg.size,
359
+ rank: meta ? importanceRank(meta.importance_max) : 0,
360
+ });
361
+ }
362
+ }
363
+
364
+ let usage = (listing.active ? listing.active.size : 0) + live.reduce((s, seg) => s + seg.size, 0);
365
+
366
+ // Hard cap: evict lowest-importance first, oldest (lowest seq) on ties.
367
+ live.sort((a, b) => a.rank - b.rank || a.seq - b.seq);
368
+ while (usage > p.maxTotalBytes && live.length > 0) {
369
+ const victim = live.shift();
370
+ await removeSegment(resolved.root, victim.name, timeoutMs);
371
+ usage -= victim.size;
372
+ expired += 1;
373
+ }
374
+
375
+ const disabled = usage > p.maxTotalBytes;
376
+ state.expired_segments = (state.expired_segments || 0) + expired;
377
+ if (disabled) state.disabled = true;
378
+ await writeState(resolved.root, state, timeoutMs);
379
+
380
+ return { ok: true, expired, disabled };
381
+ }