@ngockhoale/ukit 3.0.12 → 3.1.1

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 (66) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +1 -0
  3. package/manifests/documentation.yaml +12 -0
  4. package/manifests/platform.full.yaml +24 -0
  5. package/package.json +1 -1
  6. package/scripts/bench/data-foundation.mjs +368 -50
  7. package/src/cli/commands/doctor.js +232 -3
  8. package/src/cli/commands/feedback.js +64 -1
  9. package/src/cli/commands/install.js +18 -0
  10. package/src/cli/commands/memory.js +42 -37
  11. package/src/cli/commands/telemetry.js +460 -0
  12. package/src/cli/index.js +7 -0
  13. package/src/core/agentRuntime/adapters.js +83 -2
  14. package/src/core/agentRuntime/diagnostics.js +104 -0
  15. package/src/core/agentRuntime/supervisor.js +137 -0
  16. package/src/core/agentRuntime/telemetry.js +204 -0
  17. package/src/core/memory/memoryEmit.js +131 -0
  18. package/src/core/memory/memoryHit.js +1 -1
  19. package/src/core/memory/migrate.js +18 -11
  20. package/src/core/memory/migrateMapping.js +15 -7
  21. package/src/core/memory/mutateMemory.js +22 -4
  22. package/src/core/memory/recordIndex.js +10 -3
  23. package/src/core/memory/recordStore.js +28 -3
  24. package/src/core/memory/retrieval.js +79 -38
  25. package/src/core/memory/store.js +37 -37
  26. package/src/core/memory/storeV2.js +28 -25
  27. package/src/core/memory/storeV2Loader.js +2 -2
  28. package/src/core/observability/adapters/ingest.js +576 -0
  29. package/src/core/observability/analytics/anomalies.js +415 -0
  30. package/src/core/observability/analytics/summary.js +16 -1
  31. package/src/core/observability/emit/config.js +69 -1
  32. package/src/core/observability/emit/crash.js +434 -0
  33. package/src/core/observability/emit/lifecycle.js +349 -0
  34. package/src/core/observability/emit/recorder.js +135 -9
  35. package/src/core/observability/evaluation/aiPacket.js +52 -10
  36. package/src/core/observability/evaluation/outcomes.js +95 -0
  37. package/src/core/observability/evaluation/runner.js +225 -0
  38. package/src/core/observability/privacy/allowlist.js +23 -3
  39. package/src/core/observability/schema/compatibility.js +48 -3
  40. package/src/core/observability/schema/constants.js +5 -0
  41. package/src/core/observability/schema/registry.js +57 -0
  42. package/src/core/observability/schema/validate.js +68 -6
  43. package/src/core/observability/segments/internal.js +42 -8
  44. package/src/core/observability/segments/readSegments.js +35 -1
  45. package/src/core/observability/segments/recovery.js +3 -2
  46. package/src/core/observability/segments/retention.js +137 -33
  47. package/src/core/observability/support/projector.js +88 -18
  48. package/src/core/observability/support/provision.js +160 -0
  49. package/src/core/observability/support/renderer.js +2 -2
  50. package/src/core/observability/support/schedule.js +174 -0
  51. package/src/decision/registry.js +144 -0
  52. package/src/decision/reviewVerdict.js +309 -0
  53. package/template_project/.claude/agents/code-reviewer.md +25 -1
  54. package/template_project/.claude/agents/ukit-small-task-maintainer.md +16 -0
  55. package/template_project/.claude/commands/ukit/handoff-fullstack.md +2 -0
  56. package/template_project/.claude/commands/ukit/handoff-review.md +12 -0
  57. package/template_project/.claude/hooks/auto-allow-bash.sh +7 -1
  58. package/template_project/.claude/hooks/auto-prune-bash.sh +16 -7
  59. package/template_project/.claude/hooks/verification-guard.sh +13 -4
  60. package/template_project/.claude/ukit/index/review-verdict.mjs +592 -0
  61. package/template_project/.claude/ukit/index/sidecar-decision.mjs +595 -0
  62. package/template_project/.claude/ukit/index/unic-decision.mjs +10 -1
  63. package/template_project/.claude/ukit/runtime/async-lock.mjs +26 -0
  64. package/template_project/.codex/settings.json +3 -0
  65. package/template_project/.omp/agents/code-reviewer.md +25 -1
  66. package/template_project/.omp/agents/ukit-small-task-maintainer.md +16 -0
@@ -0,0 +1,415 @@
1
+ /**
2
+ * anomalies.js (TASK-011, SPEC §5 DF2-FR11, §7) — deterministic anomaly
3
+ * detection over TraceSummaries.
4
+ *
5
+ * detectAnomalies(summaries, opts?) → AnomalyRecord[]
6
+ *
7
+ * Pure function. No IO, no clock, no randomness — the same summaries
8
+ * always produce the same anomaly records. Output order is deterministic
9
+ * (trace_id, then kind).
10
+ *
11
+ * Detectors:
12
+ * duration-outlier — critical_path_ms > OUTLIER_FACTOR × the
13
+ * cohort p50 (cohort = structural
14
+ * fingerprint; requires ≥ MIN_COHORT
15
+ * measured members — the honest-sample
16
+ * rule, mirroring summary.js: below the
17
+ * floor the claim does not exist).
18
+ * retry-storm — ≥ RETRY_STORM_MIN spans with
19
+ * attempt_index > 1 against one operation.
20
+ * cache-miss-spike — ≥ CACHE_MIN_LOOKUPS lookups AND
21
+ * misses > CACHE_MISS_FACTOR × hits.
22
+ * drop-rate-spike — ≥ DROP_SPIKE_MIN telemetry.dropped
23
+ * events inside one trace.
24
+ * verification-failure-cluster — ≥ FAILURE_CLUSTER_MIN failed spans
25
+ * inside one trace.
26
+ *
27
+ * Each hit emits ONE anomaly.detected derived record per trace per
28
+ * detector:
29
+ * payload: { detector, kind, evidence_refs[],
30
+ * confidence: { type: 'STATISTICAL', value },
31
+ * cohort_key }
32
+ *
33
+ * Contract:
34
+ * - evidence_refs contain ONLY real record_ids. With evidence available
35
+ * inside the summary that means the pattern spans' start/end record
36
+ * ids; event-level detectors (cache/drop) can additionally cite the
37
+ * source records via opts.evidenceRecords (Map<trace_id, record[]>) —
38
+ * outside sources are resolved through the map and never invented.
39
+ * A hit that cannot cite any real record_id is never emitted.
40
+ * - cohort_key is the structural fingerprint hash of the summary's
41
+ * cohort (computeFingerprint) — the grouping identity, never a
42
+ * per-trace identity.
43
+ * - confidence.value ∈ (0, 1]: the measured signal ÷ the threshold that
44
+ * defines the anomaly, capped at 0.99 — detection is statistical
45
+ * evidence, never certainty.
46
+ * - The records are semantic-envelope SKELETONS: trace_id, origin.
47
+ * derived_from and payload are complete; boot_id/writer_id/sequence/
48
+ * wall_time_utc/monotonic_ns are deliberately absent — the emit lane
49
+ * stamps them on persistence (fillEnvelope). The records are
50
+ * deterministic and IO-free.
51
+ * - Malformed input violates the contract: non-array input or a
52
+ * summary missing the TraceSummary shape → TypeError. An EMPTY spans
53
+ * array is valid — summarizeTrace emits it for event-only traces.
54
+ */
55
+
56
+ import { computeFingerprint } from './fingerprints.js';
57
+
58
+ /** Cohort floor for relative claims — mirrors opportunities.js. */
59
+ export const MIN_COHORT = 3;
60
+
61
+ /** Duration outlier: critical path above this multiple of cohort p50. */
62
+ const OUTLIER_FACTOR = 2;
63
+ /** Retry storm: retries (attempt_index > 1) of a single operation. */
64
+ const RETRY_STORM_MIN = 3;
65
+ /** Cache miss spike: lookups floor + misses ÷ hits factor. */
66
+ const CACHE_MIN_LOOKUPS = 3;
67
+ const CACHE_MISS_FACTOR = 2;
68
+ /** Drop spike: telemetry.dropped events in one trace. */
69
+ const DROP_SPIKE_MIN = 3;
70
+ /** Verification-failure cluster: traces in one fingerprint cohort whose
71
+ * verification gate returned outcome 'failed'. Cohort floor = MIN_COHORT. */
72
+ const VERIFICATION_CLUSTER_MIN = MIN_COHORT;
73
+
74
+ function isPlainObject(value) {
75
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
76
+ }
77
+
78
+ function num(value) {
79
+ return typeof value === 'number' && Number.isFinite(value) ? value : 0;
80
+ }
81
+
82
+ /** Median of a sorted finite list (nearest rank, never interpolated). */
83
+ function medianOf(sorted) {
84
+ if (sorted.length === 0) return null;
85
+ return sorted[Math.floor((sorted.length - 1) / 2)];
86
+ }
87
+
88
+ /**
89
+ * The critical path's dominant span chain, root-first: the root span that
90
+ * contributes the maximum causal path, then the longest measured child at
91
+ * each level. Used to cite the spans that embody a duration outlier.
92
+ */
93
+ function criticalPathSpans(summary) {
94
+ const spans = summary.spans.filter(isPlainObject);
95
+ const children = new Map();
96
+ const roots = [];
97
+ const byId = new Set(spans.map((s) => s.span_id));
98
+ for (const span of spans) {
99
+ if (span.parent_span_id && byId.has(span.parent_span_id)) {
100
+ const list = children.get(span.parent_span_id) || [];
101
+ list.push(span);
102
+ children.set(span.parent_span_id, list);
103
+ } else {
104
+ roots.push(span);
105
+ }
106
+ }
107
+
108
+ function pathOf(span) {
109
+ let best = typeof span.duration_ms === 'number' ? span.duration_ms : null;
110
+ let minStart = Infinity;
111
+ let maxEnd = -Infinity;
112
+ for (const kid of children.get(span.span_id) || []) {
113
+ const kidPath = pathOf(kid);
114
+ if (kidPath !== null && (best === null || kidPath > best)) best = kidPath;
115
+ if (typeof kid.start_ns === 'number' && typeof kid.end_ns === 'number') {
116
+ if (kid.start_ns < minStart) minStart = kid.start_ns;
117
+ if (kid.end_ns > maxEnd) maxEnd = kid.end_ns;
118
+ }
119
+ }
120
+ if (maxEnd >= minStart) {
121
+ const extent = (maxEnd - minStart) / 1e6;
122
+ if (best === null || extent > best) best = extent;
123
+ }
124
+ return best;
125
+ }
126
+
127
+ let root = null;
128
+ let bestPath = null;
129
+ for (const candidate of roots) {
130
+ const path = pathOf(candidate);
131
+ if (path !== null && (bestPath === null || path > bestPath)) {
132
+ bestPath = path;
133
+ root = candidate;
134
+ }
135
+ }
136
+ if (root === null) return [];
137
+
138
+ const chain = [root];
139
+ let cursor = root;
140
+ for (;;) {
141
+ const kids = children.get(cursor.span_id) || [];
142
+ let bestKid = null;
143
+ let bestKidPath = null;
144
+ for (const kid of kids) {
145
+ const kidPath = pathOf(kid);
146
+ if (kidPath !== null && (bestKidPath === null || kidPath > bestKidPath)) {
147
+ bestKidPath = kidPath;
148
+ bestKid = kid;
149
+ }
150
+ }
151
+ if (bestKid === null) break;
152
+ chain.push(bestKid);
153
+ cursor = bestKid;
154
+ }
155
+ return chain;
156
+ }
157
+
158
+
159
+ /** All resolvable record ids carried by a span (start first, end second). */
160
+ function recordRefsOf(span) {
161
+ const refs = [];
162
+ if (typeof span.start_record_id === 'string' && span.start_record_id.length > 0) {
163
+ refs.push(span.start_record_id);
164
+ }
165
+ if (typeof span.end_record_id === 'string' && span.end_record_id.length > 0) {
166
+ refs.push(span.end_record_id);
167
+ }
168
+ return refs;
169
+ }
170
+
171
+ /**
172
+ * Fallback citation when the anomaly has no dedicated span set: the root
173
+ * span of the trace (the unit the detector ran over). Real ids only.
174
+ */
175
+ function rootRefsOf(summary) {
176
+ for (const span of summary.spans) {
177
+ if (isPlainObject(span) && (span.parent_span_id === null || span.parent_span_id === undefined)) {
178
+ const refs = recordRefsOf(span);
179
+ if (refs.length > 0) return refs;
180
+ }
181
+ }
182
+ // No root — cite the first span that carries ids rather than nothing.
183
+ for (const span of summary.spans) {
184
+ if (!isPlainObject(span)) continue;
185
+ const refs = recordRefsOf(span);
186
+ if (refs.length > 0) return refs;
187
+ }
188
+ return [];
189
+ }
190
+
191
+ /** Record ids of source records matching a semantic name (event evidence). */
192
+ function eventRefsOf(sourceRecords, semanticName) {
193
+ const refs = [];
194
+ for (const record of sourceRecords || []) {
195
+ if (isPlainObject(record) && record.semantic_name === semanticName &&
196
+ typeof record.record_id === 'string' && record.record_id.length > 0) {
197
+ refs.push(record.record_id);
198
+ }
199
+ }
200
+ return refs;
201
+ }
202
+
203
+ /** Confidence: measured signal ÷ the threshold that defines the anomaly —
204
+ * a statistical strength ratio (may exceed 1), never certainty. */
205
+ function confidenceOf(value) {
206
+ if (!Number.isFinite(value) || value <= 0) return null;
207
+ return Math.round(value * 1000) / 1000;
208
+ }
209
+
210
+ function anomalyRecord(summary, fingerprint, kind, evidenceRefs, confidence) {
211
+ const refs = [...new Set(evidenceRefs)].sort();
212
+ if (refs.length === 0 || confidence === null) return null;
213
+ return {
214
+ record_type: 'derived',
215
+ semantic_name: 'anomaly.detected',
216
+ record_id: `anm-${fingerprint.hash}-${kind}`,
217
+ trace_id: typeof summary.trace_id === 'string' ? summary.trace_id : null,
218
+ importance: 'high',
219
+ privacy_class: 'internal',
220
+ origin: { derived_from: refs.slice() },
221
+ payload: {
222
+ detector: kind,
223
+ kind,
224
+ evidence_refs: refs,
225
+ confidence: { type: 'STATISTICAL', value: confidence },
226
+ cohort_key: fingerprint.hash,
227
+ },
228
+ };
229
+ }
230
+
231
+ /**
232
+ * @param {TraceSummary[]} summaries
233
+ * @param {object} [opts]
234
+ * @param {Map<string, object[]|null>|object} [opts.evidenceRecords] —
235
+ * trace_id → source records, for event-level evidence (telemetry.dropped,
236
+ * cache.*). Absent entries fall back to span-level ids.
237
+ * @returns {object[]} anomaly.detected derived-record skeletons
238
+ */
239
+ export function detectAnomalies(summaries, opts = {}) {
240
+ if (!Array.isArray(summaries)) {
241
+ throw new TypeError('detectAnomalies: summaries must be an array of TraceSummary');
242
+ }
243
+ for (const summary of summaries) {
244
+ // A valid TraceSummary may carry spans: [] — event-only traces produce
245
+ // it and the event-level detectors (cache/drop/verification) still
246
+ // apply. What is malformed is an object that is not a TraceSummary at
247
+ // all: coverage and durations accompany every summarizeTrace result,
248
+ // so requiring them keeps genuine shape violations a TypeError.
249
+ if (
250
+ !isPlainObject(summary) ||
251
+ !Array.isArray(summary.spans) ||
252
+ !isPlainObject(summary.coverage) ||
253
+ !isPlainObject(summary.durations)
254
+ ) {
255
+ throw new TypeError(
256
+ 'detectAnomalies: each summary must be a TraceSummary object with spans, coverage and durations'
257
+ );
258
+ }
259
+ }
260
+
261
+ const evidenceRecords = isPlainObject(opts.evidenceRecords) || opts.evidenceRecords instanceof Map
262
+ ? opts.evidenceRecords
263
+ : null;
264
+ const sourceRecordsOf = (traceId) => {
265
+ if (!evidenceRecords || typeof traceId !== 'string') return [];
266
+ if (evidenceRecords instanceof Map) return evidenceRecords.get(traceId) || [];
267
+ return evidenceRecords[traceId] || [];
268
+ };
269
+
270
+ // Fingerprint cohorts — the comparison group for relative detectors.
271
+ const bySummary = summaries.map((summary) => ({
272
+ summary,
273
+ fingerprint: computeFingerprint(summary),
274
+ }));
275
+ const cohortMembers = new Map();
276
+ for (const { fingerprint } of bySummary) {
277
+ cohortMembers.set(fingerprint.hash, (cohortMembers.get(fingerprint.hash) || 0) + 1);
278
+ }
279
+ // Cohort duration distribution (measured critical paths only — unmeasured
280
+ // traces are excluded from the baseline, never counted as 0).
281
+ const cohortDurations = new Map();
282
+ for (const { summary, fingerprint } of bySummary) {
283
+ if (typeof summary.critical_path_ms === 'number' && Number.isFinite(summary.critical_path_ms)) {
284
+ const list = cohortDurations.get(fingerprint.hash) || [];
285
+ list.push(summary.critical_path_ms);
286
+ cohortDurations.set(fingerprint.hash, list);
287
+ }
288
+ }
289
+ for (const list of cohortDurations.values()) list.sort((a, b) => a - b);
290
+
291
+ const anomalies = [];
292
+ const push = (record) => {
293
+ if (record) anomalies.push(record);
294
+ };
295
+
296
+ for (const { summary, fingerprint } of bySummary) {
297
+ const sourceRecords = sourceRecordsOf(summary.trace_id);
298
+
299
+ // --- duration-outlier (cohort-relative, honest-sample floor) ---
300
+ const durations = cohortDurations.get(fingerprint.hash) || [];
301
+ const members = cohortMembers.get(fingerprint.hash) || 0;
302
+ const median = members >= MIN_COHORT && durations.length >= MIN_COHORT
303
+ ? medianOf(durations)
304
+ : null;
305
+ const path = typeof summary.critical_path_ms === 'number' &&
306
+ Number.isFinite(summary.critical_path_ms)
307
+ ? summary.critical_path_ms
308
+ : null;
309
+ if (median !== null && path !== null && path > OUTLIER_FACTOR * median) {
310
+ const chainRefs = criticalPathSpans(summary).flatMap(recordRefsOf);
311
+ push(anomalyRecord(
312
+ summary,
313
+ fingerprint,
314
+ 'duration-outlier',
315
+ chainRefs.length > 0 ? chainRefs : rootRefsOf(summary),
316
+ confidenceOf(path / (OUTLIER_FACTOR * median)),
317
+ ));
318
+ }
319
+
320
+ // --- retry-storm (≥N retried attempts of one operation) ---
321
+ const retriesByOp = new Map();
322
+ for (const span of summary.spans) {
323
+ if (!isPlainObject(span) || !(Number.isInteger(span.attempt_index) && span.attempt_index > 1)) continue;
324
+ const op = typeof span.operation === 'string' ? span.operation : 'unknown';
325
+ const list = retriesByOp.get(op) || [];
326
+ list.push(span);
327
+ retriesByOp.set(op, list);
328
+ }
329
+ for (const spans of retriesByOp.values()) {
330
+ if (spans.length >= RETRY_STORM_MIN) {
331
+ push(anomalyRecord(
332
+ summary,
333
+ fingerprint,
334
+ 'retry-storm',
335
+ spans.flatMap(recordRefsOf),
336
+ confidenceOf(spans.length / RETRY_STORM_MIN),
337
+ ));
338
+ break; // one record per trace per detector — evidence is per-trace
339
+ }
340
+ }
341
+
342
+ // --- cache-miss-spike ---
343
+ const cache = isPlainObject(summary.cache) ? summary.cache : {};
344
+ const hits = num(cache.hits);
345
+ const misses = num(cache.misses);
346
+ const lookups = hits + misses;
347
+ if (lookups >= CACHE_MIN_LOOKUPS && misses > CACHE_MISS_FACTOR * hits) {
348
+ const refs = [...eventRefsOf(sourceRecords, 'cache.miss'), ...eventRefsOf(sourceRecords, 'cache.hit')];
349
+ push(anomalyRecord(
350
+ summary,
351
+ fingerprint,
352
+ 'cache-miss-spike',
353
+ refs.length > 0 ? refs : rootRefsOf(summary),
354
+ confidenceOf(misses / Math.max(1, CACHE_MISS_FACTOR * hits)),
355
+ ));
356
+ }
357
+
358
+ // --- drop-rate-spike ---
359
+ const drops = isPlainObject(summary.drops) ? summary.drops : {};
360
+ if (num(drops.events) >= DROP_SPIKE_MIN) {
361
+ const refs = eventRefsOf(sourceRecords, 'telemetry.dropped');
362
+ push(anomalyRecord(
363
+ summary,
364
+ fingerprint,
365
+ 'drop-rate-spike',
366
+ refs.length > 0 ? refs : rootRefsOf(summary),
367
+ confidenceOf(num(drops.events) / DROP_SPIKE_MIN),
368
+ ));
369
+ }
370
+
371
+ // --- verification-failure evidence: collect per cohort below ---
372
+ }
373
+
374
+ // --- verification-failure cluster (cohort-level) ---
375
+ // A structural cohort in which ≥ VERIFICATION_CLUSTER_MIN traces failed
376
+ // their verification gate is a cluster, not a fluke. Evidence is the
377
+ // failed verification record ids carried by summary.verification.
378
+ const cohorts = new Map();
379
+ for (const { summary, fingerprint } of bySummary) {
380
+ let cohort = cohorts.get(fingerprint.hash);
381
+ if (!cohort) {
382
+ cohort = { fingerprint, summaries: [] };
383
+ cohorts.set(fingerprint.hash, cohort);
384
+ }
385
+ cohort.summaries.push(summary);
386
+ }
387
+ for (const cohort of cohorts.values()) {
388
+ const failedSummaries = cohort.summaries.filter((s) => {
389
+ const v = isPlainObject(s.verification) ? s.verification : {};
390
+ return num(v.failed) > 0;
391
+ });
392
+ if (failedSummaries.length < VERIFICATION_CLUSTER_MIN) continue;
393
+ const refs = [];
394
+ for (const s of failedSummaries) {
395
+ const v = isPlainObject(s.verification) ? s.verification : {};
396
+ if (Array.isArray(v.failed_refs)) refs.push(...v.failed_refs.filter((r) => typeof r === 'string' && r.length > 0));
397
+ }
398
+ push(anomalyRecord(
399
+ { trace_id: null },
400
+ cohort.fingerprint,
401
+ 'verification-failure-cluster',
402
+ refs,
403
+ confidenceOf(failedSummaries.length / VERIFICATION_CLUSTER_MIN),
404
+ ));
405
+ }
406
+
407
+ // Deterministic output order: trace_id, then kind.
408
+ anomalies.sort((a, b) => {
409
+ const at = String(a.trace_id);
410
+ const bt = String(b.trace_id);
411
+ if (at !== bt) return at < bt ? -1 : 1;
412
+ return a.payload.kind < b.payload.kind ? -1 : a.payload.kind > b.payload.kind ? 1 : 0;
413
+ });
414
+ return anomalies;
415
+ }
@@ -8,7 +8,8 @@
8
8
  *
9
9
  * TraceSummary shape (SPEC §8):
10
10
  * { trace_id, spans[], durations{}, critical_path_ms, retries, drops,
11
- * cache{}, resource{}, telemetry_complete, coverage{}, metric_version }
11
+ * cache{}, resource{}, verification{completed,failed,failed_refs[]},
12
+ * telemetry_complete, coverage{}, metric_version }
12
13
  *
13
14
  * Rules that make this a flight recorder and not a dashboard:
14
15
  * - critical_path_ms is CAUSAL: a span contributes its own measured
@@ -100,6 +101,7 @@ export function summarizeTrace(records) {
100
101
  const cache = { observed: false, hits: 0, misses: 0, hit_rate: null };
101
102
  const resource = { total_value: null, numeric_count: 0, unknown_count: 0, sources: {} };
102
103
  const retries = { model_attempts: 0, retries: 0, failed_spans: 0 };
104
+ const verification = { completed: 0, failed: 0, failed_refs: [] };
103
105
  const coverage = {
104
106
  records: ordered.length,
105
107
  records_with_trace: 0,
@@ -156,6 +158,18 @@ export function summarizeTrace(records) {
156
158
  }
157
159
  }
158
160
 
161
+ // Verification gate outcomes (event-level, not span pairs): outcome
162
+ // verdict + the record's own id for downstream evidence citation.
163
+ if (name === 'verification.completed') {
164
+ verification.completed += 1;
165
+ if (payload.outcome === 'failed') {
166
+ verification.failed += 1;
167
+ if (typeof record.record_id === 'string' && record.record_id.length > 0) {
168
+ verification.failed_refs.push(record.record_id);
169
+ }
170
+ }
171
+ }
172
+
159
173
  if (name === 'cache.hit') cache.hits += 1;
160
174
  if (name === 'cache.miss') cache.misses += 1;
161
175
  if (name === 'telemetry.dropped') {
@@ -290,6 +304,7 @@ export function summarizeTrace(records) {
290
304
  retries,
291
305
  drops,
292
306
  cache,
307
+ verification,
293
308
  resource,
294
309
  telemetry_complete,
295
310
  coverage,
@@ -2,7 +2,9 @@
2
2
  * config.js (TASK-007, SPEC §8 / §13) — observability stage gate.
3
3
  *
4
4
  * resolveStage(config) → 'off' | 'shadow' | 'canary' | 'default'
5
- *
5
+ * resolveSampling(config) → { policy_version, rates } | null (DF2-FR02)
6
+ * samplingConfigInvalid() → boolean — one-shot latch, true after a
7
+ * malformed `observability.sampling` node was seen (once per boot).
6
8
  * Reads `observability.stage` from the project runtime config object.
7
9
  * Stages promote off → shadow → canary → default; the kill switch is
8
10
  * setting the value back to 'off'. Absent config, absent key, non-object
@@ -27,3 +29,69 @@ export function resolveStage(config = null) {
27
29
  const stage = isPlainObject(node) ? node.stage : undefined;
28
30
  return VALID_STAGES.has(stage) ? stage : 'off';
29
31
  }
32
+
33
+ // --- DF2-FR02: sampling policy -------------------------------------------
34
+ //
35
+ // `observability.sampling = { policy_version, rates }` where rates keys are
36
+ // the frozen IMPORTANCE_LEVELS vocabulary (low|normal|high|critical) with
37
+ // values in [0, 1]; absent levels default to 1.0 (keep everything at that
38
+ // level). resolveSampling mirrors resolveStage's fail-safe posture:
39
+ // absent sampling node → null (feature off); ANY malformed part — wrong
40
+ // type, unknown rate key, rate outside [0,1], missing/oversize
41
+ // policy_version — → null AND latches the CONFIG_INVALID flag so the
42
+ // recorder can report the configuration failure exactly once per boot
43
+ // (telemetry.dropped{reason_code:'CONFIG_INVALID'}). Guessing at a partial
44
+ // policy would silently drop data the operator never asked to drop, so a
45
+ // bad config disables sampling wholesale.
46
+
47
+ const SAMPLING_RATE_KEYS = new Set(['low', 'normal', 'high', 'critical']);
48
+ const MAX_POLICY_VERSION_CHARS = 128;
49
+
50
+ // Process-wide latch: a recorder boot is a process, so "once per boot" is a
51
+ // module-level one-shot — the same granularity resolveStage operates at.
52
+ let samplingConfigInvalidSeen = false;
53
+
54
+ export function samplingConfigInvalid() {
55
+ return samplingConfigInvalidSeen;
56
+ }
57
+
58
+ // Test/secondary-consumer hook: the flag is a boot-scoped observation, not
59
+ // stateful policy — clearing it re-arms the one-shot report.
60
+ export function resetSamplingConfigInvalid() {
61
+ samplingConfigInvalidSeen = false;
62
+ }
63
+
64
+ export function resolveSampling(config = null) {
65
+ const node = isPlainObject(config) ? config.observability : undefined;
66
+ if (!isPlainObject(node)) return null;
67
+ const sampling = node.sampling;
68
+ if (sampling === undefined || sampling === null) return null; // absent: off
69
+
70
+ const invalid = () => {
71
+ samplingConfigInvalidSeen = true;
72
+ return null;
73
+ };
74
+
75
+ if (!isPlainObject(sampling)) return invalid();
76
+ const { policy_version, rates } = sampling;
77
+ if (
78
+ typeof policy_version !== 'string'
79
+ || policy_version.length === 0
80
+ || policy_version.length > MAX_POLICY_VERSION_CHARS
81
+ ) {
82
+ return invalid();
83
+ }
84
+ if (!isPlainObject(rates)) return invalid();
85
+ const out = {};
86
+ for (const [key, value] of Object.entries(rates)) {
87
+ if (!SAMPLING_RATE_KEYS.has(key)) return invalid();
88
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > 1) {
89
+ return invalid();
90
+ }
91
+ out[key] = value;
92
+ }
93
+ for (const level of SAMPLING_RATE_KEYS) {
94
+ if (out[level] === undefined) out[level] = 1;
95
+ }
96
+ return { policy_version, rates: out };
97
+ }