@ngockhoale/ukit 3.0.6 → 3.0.8

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 +16 -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,113 @@
1
+ /**
2
+ * redaction.js (TASK-004)
3
+ *
4
+ * Shared low-level string hygiene for the two privacy gates. This module
5
+ * only knows how to *detect and neutralize* forbidden content inside a
6
+ * single string — it makes no keep/drop/reject decisions. Those stay with
7
+ * each gate so the support boundary remains an independent check.
8
+ *
9
+ * Leak-safety contract (same as sensitiveValueScanner.js): nothing here
10
+ * returns a matched value, excerpt, or hash — only redacted strings and
11
+ * boolean verdicts.
12
+ */
13
+
14
+ /**
15
+ * Ordered redaction rules. Each rule replaces every match with
16
+ * `[REDACTED:<tag>]`. Vendor-token patterns mirror TOKEN_PATTERNS in
17
+ * src/core/sensitiveValueScanner.js so a value the scanner would flag is
18
+ * also removable here; the scanner is still consulted afterwards as a
19
+ * second signal for anything these patterns miss.
20
+ */
21
+ export const REDACTION_RULES = [
22
+ { tag: 'secret', re: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY-----/g },
23
+ { tag: 'secret', re: /\bsk-(?:proj-|ant-|svc-|acct-|admin-)?[A-Za-z0-9_-]{20,}/g },
24
+ { tag: 'secret', re: /(?<![A-Za-z0-9+/_-])(?:AKIA|ASIA)[0-9A-Z]{16}(?![A-Za-z0-9+/_-])/g },
25
+ { tag: 'secret', re: /\bgithub_pat_[A-Za-z0-9_]{20,}/g },
26
+ { tag: 'secret', re: /\bgh[pousr]_[A-Za-z0-9]{30,}\b/g },
27
+ { tag: 'secret', re: /\bglpat-[A-Za-z0-9_-]{20,}/g },
28
+ { tag: 'secret', re: /\bxox[baprs]-[A-Za-z0-9-]{10,}/g },
29
+ { tag: 'secret', re: /(?<![A-Za-z0-9+/_-])AIza[0-9A-Za-z_-]{20,}(?![A-Za-z0-9+/_-])/g },
30
+ { tag: 'secret', re: /\b[srp]k_live_[A-Za-z0-9]{20,}/g },
31
+ { tag: 'secret', re: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g },
32
+ { tag: 'secret', re: /\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/gi },
33
+ { tag: 'secret', re: /\bssh-(?:rsa|ed25519|dss)\s+[A-Za-z0-9+/=]{20,}/g },
34
+ // scheme://user:pass@host — credentials and embedded hostnames
35
+ { tag: 'secret', re: /\b[a-z][a-z0-9+.-]*:\/\/[^\s/:@]+:[^\s/@]+@[^\s]+/gi },
36
+ // KEY=value assignments for well-known secret names (.env content)
37
+ {
38
+ tag: 'secret',
39
+ re: /\b[A-Za-z_][A-Za-z0-9_]*(?:KEY|SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIAL)[A-Za-z0-9_]*\s*=\s*[^\s"']+/g,
40
+ },
41
+ // absolute POSIX paths (two or more segments)
42
+ { tag: 'path', re: /\/(?:[\w.@+-]+)(?:\/[\w.@+-]+)+/g },
43
+ // absolute Windows paths
44
+ { tag: 'path', re: /\b[A-Za-z]:\\(?:[^\s"']+\\)*[^\s"']*/g },
45
+ // email addresses
46
+ { tag: 'pii', re: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g },
47
+ ];
48
+
49
+ /** C0/C1 controls, DEL, bidi/format overrides, zero-width chars, BOM. */
50
+ const FORBIDDEN_CHARS_RE =
51
+ /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F\u00AD\u2028-\u202E\u2066-\u2069\u200B-\u200D\u2060\uFEFF\uFFF0-\uFFFF]/g;
52
+
53
+ /**
54
+ * True when the string contains a line break. Persisted telemetry is
55
+ * single-line JSONL; any embedded newline is treated as a log-injection
56
+ * attempt and the whole value is redacted rather than filtered.
57
+ */
58
+ export function hasLineBreak(value) {
59
+ return value.includes('\n') || value.includes('\r');
60
+ }
61
+
62
+ /**
63
+ * True when the string still contains content a redaction rule would
64
+ * remove, a line break, or a forbidden control/format character. Used by
65
+ * the support gate to *reject* rather than redact.
66
+ */
67
+ export function containsForbiddenContent(value) {
68
+ if (typeof value !== 'string') return false;
69
+ if (hasLineBreak(value)) return true;
70
+ for (const { re } of REDACTION_RULES) {
71
+ re.lastIndex = 0;
72
+ if (re.test(value)) return true;
73
+ }
74
+ FORBIDDEN_CHARS_RE.lastIndex = 0;
75
+ return FORBIDDEN_CHARS_RE.test(value);
76
+ }
77
+
78
+ /**
79
+ * Redact a single string. Returns the neutralized value — never throws.
80
+ * `scanner` is the sensitiveValueScanner.scanText-compatible second
81
+ * signal; when it flags (or throws) the whole value collapses to a marker
82
+ * because we cannot safely locate what it saw.
83
+ */
84
+ export function redactString(value, { scanner, maxChars }) {
85
+ let out = value;
86
+
87
+ if (hasLineBreak(out)) return '[REDACTED:multiline]';
88
+
89
+ for (const { tag, re } of REDACTION_RULES) {
90
+ re.lastIndex = 0;
91
+ out = out.replace(re, `[REDACTED:${tag}]`);
92
+ }
93
+
94
+ FORBIDDEN_CHARS_RE.lastIndex = 0;
95
+ out = out.replace(FORBIDDEN_CHARS_RE, '');
96
+
97
+ if (typeof scanner === 'function') {
98
+ let hit = false;
99
+ try {
100
+ const res = scanner(out);
101
+ hit = Boolean(res && res.hasSecret);
102
+ } catch {
103
+ hit = true; // fail closed: a broken scanner never passes content
104
+ }
105
+ if (hit) return '[REDACTED:secret]';
106
+ }
107
+
108
+ if (out.length > maxChars) {
109
+ const marker = '…[TRUNCATED]';
110
+ out = `${out.slice(0, Math.max(0, maxChars - marker.length))}${marker}`;
111
+ }
112
+ return out;
113
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * sanitizeForSupport.js (TASK-004, SPEC §5 DF-FR06 / §8)
3
+ *
4
+ * Independent second boundary in front of the user-facing support view.
5
+ * Unlike sanitizeObserved it does not repair content — it REJECTS:
6
+ * unknown envelope fields, non-allowlisted payload fields (default-deny,
7
+ * applied recursively), any residual forbidden content, oversize values,
8
+ * and records that were never stamped by the observed gate.
9
+ *
10
+ * Signature: sanitizeForSupport(record, options?)
11
+ * → { ok: true, record } | { ok: false, reason }
12
+ * `reason` is a static code — it never echoes matched content.
13
+ */
14
+
15
+ import { scanText } from '../../sensitiveValueScanner.js';
16
+ import {
17
+ ALLOWED_FIELDS,
18
+ ALLOWED_SUPPORT_PAYLOAD_FIELDS,
19
+ REDACTION_VERSION,
20
+ MAX_SUPPORT_RECORD_BYTES,
21
+ MAX_SUPPORT_STRING_CHARS,
22
+ MAX_OBJECT_KEYS,
23
+ MAX_ARRAY_ITEMS,
24
+ MAX_DEPTH,
25
+ } from './allowlist.js';
26
+ import { containsForbiddenContent } from './redaction.js';
27
+
28
+ function isPlainObject(value) {
29
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
30
+ }
31
+
32
+ /**
33
+ * Recursive support-side check. Returns a static rejection reason or null.
34
+ * Every object level — payload root included — is held to
35
+ * ALLOWED_SUPPORT_PAYLOAD_FIELDS: the support view carries codes, counts
36
+ * and timings only, so an unrecognized key anywhere fails closed.
37
+ */
38
+ function checkValue(value, ctx, depth) {
39
+ if (value === null) return null;
40
+ const t = typeof value;
41
+ if (t === 'boolean') return null;
42
+ if (t === 'number') return Number.isFinite(value) ? null : 'non_finite_number';
43
+ if (t === 'string') {
44
+ if (value.length > MAX_SUPPORT_STRING_CHARS) return 'support_string_too_long';
45
+ if (containsForbiddenContent(value)) return 'forbidden_content';
46
+ if (typeof ctx.scanner === 'function') {
47
+ let hit = false;
48
+ try {
49
+ const res = ctx.scanner(value);
50
+ hit = Boolean(res && res.hasSecret);
51
+ } catch {
52
+ hit = true;
53
+ }
54
+ if (hit) return 'scanner_flagged';
55
+ }
56
+ return null;
57
+ }
58
+ if (depth >= MAX_DEPTH) return 'depth_exceeded';
59
+ if (Array.isArray(value)) {
60
+ if (value.length > MAX_ARRAY_ITEMS) return 'array_too_large';
61
+ for (const item of value) {
62
+ const reason = checkValue(item, ctx, depth + 1);
63
+ if (reason) return reason;
64
+ }
65
+ return null;
66
+ }
67
+ if (isPlainObject(value)) {
68
+ const keys = Object.keys(value);
69
+ if (keys.length > MAX_OBJECT_KEYS) return 'object_too_large';
70
+ for (const key of keys) {
71
+ if (typeof key !== 'string' || key.length === 0 || key.length > 128) {
72
+ return 'invalid_key';
73
+ }
74
+ if (containsForbiddenContent(key)) return 'forbidden_key';
75
+ if (!ALLOWED_SUPPORT_PAYLOAD_FIELDS.has(key)) return 'payload_field_not_allowed';
76
+ const reason = checkValue(value[key], ctx, depth + 1);
77
+ if (reason) return reason;
78
+ }
79
+ return null;
80
+ }
81
+ return 'unsupported_value';
82
+ }
83
+
84
+ /**
85
+ * @param {object} record a record expected to have passed sanitizeObserved
86
+ * @param {{ scanner?: (text: string) => { hasSecret: boolean } }} [options]
87
+ * @returns {{ ok: true, record: object } | { ok: false, reason: string }}
88
+ */
89
+ export function sanitizeForSupport(record, options = {}) {
90
+ try {
91
+ if (!isPlainObject(record)) {
92
+ return { ok: false, reason: 'record_not_object' };
93
+ }
94
+ const ctx = {
95
+ scanner: typeof options.scanner === 'function' ? options.scanner : scanText,
96
+ };
97
+
98
+ // proof the record crossed the observed gate at the current ruleset
99
+ if (record.redaction_version !== REDACTION_VERSION) {
100
+ return { ok: false, reason: 'redaction_version_missing' };
101
+ }
102
+
103
+ for (const key of Object.keys(record)) {
104
+ if (!ALLOWED_FIELDS.has(key)) return { ok: false, reason: 'envelope_field_not_allowed' };
105
+ }
106
+
107
+ if (!isPlainObject(record.payload)) {
108
+ return { ok: false, reason: 'payload_not_object' };
109
+ }
110
+ const payloadReason = checkValue(record.payload, ctx, 0);
111
+ if (payloadReason) return { ok: false, reason: payloadReason };
112
+
113
+ // envelope scalar values get the same content check
114
+ for (const [key, value] of Object.entries(record)) {
115
+ if (key === 'payload') continue;
116
+ const reason = checkValue(value, ctx, 0);
117
+ if (reason) return { ok: false, reason };
118
+ }
119
+
120
+ let serialized;
121
+ try {
122
+ serialized = JSON.stringify(record);
123
+ } catch {
124
+ return { ok: false, reason: 'serialization_failed' };
125
+ }
126
+ if (serialized.length > MAX_SUPPORT_RECORD_BYTES) {
127
+ return { ok: false, reason: 'support_record_too_large' };
128
+ }
129
+ return { ok: true, record };
130
+ } catch {
131
+ return { ok: false, reason: 'sanitizer_error' };
132
+ }
133
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * sanitizeObserved.js (TASK-004, SPEC §5 DF-FR06 / §8)
3
+ *
4
+ * Runtime canonical write gate. Runs BEFORE any persistent write: keeps
5
+ * only allowlisted envelope fields, drops deny-listed free-form payload
6
+ * fields, redacts secrets/paths/PII inside surviving values, enforces
7
+ * size/depth caps, and stamps `privacy_class` + `redaction_version`.
8
+ *
9
+ * The allowlist is the boundary; sensitiveValueScanner.scanText is one
10
+ * extra signal, never proof. A missing or broken scanner can never let a
11
+ * deny-list violation or a redactable value through.
12
+ *
13
+ * Signature: sanitizeObserved(record, options?)
14
+ * → { ok: true, record } | { ok: false, reason }
15
+ * `reason` is a static code — it never echoes matched content.
16
+ */
17
+
18
+ import { scanText } from '../../sensitiveValueScanner.js';
19
+ import {
20
+ ALLOWED_FIELDS,
21
+ DENIED_PAYLOAD_FIELDS,
22
+ REDACTION_VERSION,
23
+ MAX_RECORD_BYTES,
24
+ MAX_STRING_CHARS,
25
+ MAX_OBJECT_KEYS,
26
+ MAX_ARRAY_ITEMS,
27
+ MAX_DEPTH,
28
+ } from './allowlist.js';
29
+ import { redactString } from './redaction.js';
30
+ import { PRIVACY_CLASSES } from '../schema/constants.js';
31
+ import { SEMANTIC_REGISTRY } from '../schema/registry.js';
32
+
33
+ const VALID_PRIVACY_CLASSES = new Set(PRIVACY_CLASSES);
34
+
35
+
36
+ function isPlainObject(value) {
37
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
38
+ }
39
+
40
+ function sanitizeKey(key, ctx) {
41
+ if (typeof key !== 'string' || key.length === 0 || key.length > 128) return null;
42
+ const cleaned = redactString(key, { scanner: ctx.scanner, maxChars: 128 });
43
+ // a key that needed redaction is itself unsafe — drop the entry
44
+ if (cleaned !== key) return null;
45
+ return key;
46
+ }
47
+
48
+ function sanitizeValue(value, ctx, depth) {
49
+ if (value === null) return null;
50
+ const t = typeof value;
51
+ if (t === 'boolean') return value;
52
+ if (t === 'number') return Number.isFinite(value) ? value : null;
53
+ if (t === 'string') {
54
+ return redactString(value, { scanner: ctx.scanner, maxChars: MAX_STRING_CHARS });
55
+ }
56
+ if (depth >= MAX_DEPTH) return undefined;
57
+ if (Array.isArray(value)) {
58
+ const out = [];
59
+ for (const item of value.slice(0, MAX_ARRAY_ITEMS)) {
60
+ const v = sanitizeValue(item, ctx, depth + 1);
61
+ out.push(v === undefined ? null : v);
62
+ }
63
+ return out;
64
+ }
65
+ if (isPlainObject(value)) {
66
+ const out = {};
67
+ let kept = 0;
68
+ for (const [k, v] of Object.entries(value)) {
69
+ if (kept >= MAX_OBJECT_KEYS) break;
70
+ if (DENIED_PAYLOAD_FIELDS.has(k)) continue;
71
+ const key = sanitizeKey(k, ctx);
72
+ if (key === null) continue;
73
+ const sv = sanitizeValue(v, ctx, depth + 1);
74
+ if (sv === undefined) continue;
75
+ out[key] = sv;
76
+ kept += 1;
77
+ }
78
+ return out;
79
+ }
80
+ return undefined; // functions, symbols, bigint, class instances — dropped
81
+ }
82
+
83
+ /**
84
+ * @param {object} record candidate observability record (not mutated)
85
+ * @param {{ scanner?: (text: string) => { hasSecret: boolean } }} [options]
86
+ * scanner defaults to sensitiveValueScanner.scanText; injectable for
87
+ * tests. Pass-through of a broken/absent scanner is impossible by
88
+ * construction — deny-list and redaction rules run regardless.
89
+ * @returns {{ ok: true, record: object } | { ok: false, reason: string }}
90
+ */
91
+ export function sanitizeObserved(record, options = {}) {
92
+ try {
93
+ if (!isPlainObject(record)) {
94
+ return { ok: false, reason: 'record_not_object' };
95
+ }
96
+ const ctx = {
97
+ scanner: typeof options.scanner === 'function' ? options.scanner : scanText,
98
+ };
99
+
100
+ const out = {};
101
+ for (const [key, value] of Object.entries(record)) {
102
+ if (!ALLOWED_FIELDS.has(key)) continue;
103
+ if (key === 'payload') continue; // handled below
104
+ const sv = sanitizeValue(value, ctx, 0);
105
+ if (sv !== undefined) out[key] = sv;
106
+ }
107
+
108
+ if (!isPlainObject(record.payload)) {
109
+ return { ok: false, reason: 'payload_not_object' };
110
+ }
111
+ out.payload = sanitizeValue(record.payload, ctx, 0);
112
+
113
+ if (!VALID_PRIVACY_CLASSES.has(out.privacy_class)) {
114
+ // Missing/invalid class: stamp the registry floor for this semantic name
115
+ // (never lower a valid class — records may restrict, never loosen).
116
+ const entry = SEMANTIC_REGISTRY[out.semantic_name];
117
+ out.privacy_class = entry ? entry.privacy_class : 'internal';
118
+ }
119
+ out.redaction_version = REDACTION_VERSION;
120
+
121
+ let serialized;
122
+ try {
123
+ serialized = JSON.stringify(out);
124
+ } catch {
125
+ return { ok: false, reason: 'serialization_failed' };
126
+ }
127
+ if (serialized.length > MAX_RECORD_BYTES) {
128
+ return { ok: false, reason: 'record_too_large' };
129
+ }
130
+ return { ok: true, record: out };
131
+ } catch {
132
+ return { ok: false, reason: 'sanitizer_error' };
133
+ }
134
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * rollout.js (TASK-015, SPEC §5 DF-FR13 / §13) — per-seam staged rollout.
3
+ *
4
+ * SEAM_MIN_STAGE = { recorder: 'shadow', projector: 'canary', evaluation: 'default' }
5
+ * resolveSeamStages(config) → { recorder, projector, evaluation }
6
+ * resolveSeamConfig(config, seam) → live config view bound to that seam
7
+ * killSwitch(config, seam?) → config
8
+ *
9
+ * The single global flag `observability.stage` promotes off → shadow →
10
+ * canary → default; each seam activates at its own minimum stage, so the
11
+ * ladder is: shadow records only, canary adds the support projection,
12
+ * default adds the evaluation surface. A seam whose effective stage falls
13
+ * below its minimum resolves to 'off' — the value existing consumers
14
+ * (createRecorder, projectSupport) already treat as their kill switch.
15
+ *
16
+ * Per-seam overrides live at `observability.seams.<seam>.stage` and are
17
+ * RESTRICTIVE-ONLY: the effective stage is min(global, override) clamped
18
+ * to the seam minimum. An override can disable or lower a seam; it can
19
+ * never raise one above the global stage, so a global 'off' is absolute.
20
+ * Malformed overrides resolve to 'off' for that seam only — a bad value
21
+ * can never promote anything.
22
+ *
23
+ * killSwitch(config) writes 'off' to the global stage AND pins every seam
24
+ * override to 'off', so re-raising the global stage later cannot silently
25
+ * revive a seam — re-enabling after a kill is an explicit per-seam act.
26
+ * killSwitch(config, seam) pins only that seam. Both mutate the config in
27
+ * place and return it.
28
+ *
29
+ * resolveSeamConfig returns a live view (the `observability` node is a
30
+ * getter, not a snapshot): mutations via killSwitch or direct config edits
31
+ * are respected on the consumer's next call, matching the recorder's
32
+ * live-read contract. A view is tagged so killSwitch(view) redirects to
33
+ * the source config with the bound seam — killing through a bound view
34
+ * kills that seam, never a different one.
35
+ */
36
+
37
+ import { resolveStage } from './emit/config.js';
38
+
39
+ const STAGE_RANK = { off: 0, shadow: 1, canary: 2, default: 3 };
40
+
41
+ export const SEAM_NAMES = Object.freeze(['recorder', 'projector', 'evaluation']);
42
+
43
+ export const SEAM_MIN_STAGE = Object.freeze({
44
+ recorder: 'shadow',
45
+ projector: 'canary',
46
+ evaluation: 'default',
47
+ });
48
+
49
+ const BOUND = Symbol('ukit.rollout.bound');
50
+
51
+ function isPlainObject(value) {
52
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
53
+ }
54
+
55
+ function isValidStage(value) {
56
+ return typeof value === 'string' && value in STAGE_RANK;
57
+ }
58
+
59
+ function seamOverride(config, seam) {
60
+ const seams = isPlainObject(config) && isPlainObject(config.observability)
61
+ ? config.observability.seams
62
+ : undefined;
63
+ if (!isPlainObject(seams)) return undefined;
64
+ const node = seams[seam];
65
+ if (!isPlainObject(node)) return undefined;
66
+ // Present-but-malformed is an explicit 'off' for this seam; absent is
67
+ // undefined so the global stage applies.
68
+ return node.stage === undefined ? undefined : (isValidStage(node.stage) ? node.stage : 'off');
69
+ }
70
+
71
+ function effectiveStage(config, seam) {
72
+ const global = resolveStage(config);
73
+ const override = seamOverride(config, seam);
74
+ const rank = Math.min(STAGE_RANK[global], override === undefined ? STAGE_RANK[global] : STAGE_RANK[override]);
75
+ const stage = Object.keys(STAGE_RANK).find((s) => STAGE_RANK[s] === rank);
76
+ return rank >= STAGE_RANK[SEAM_MIN_STAGE[seam]] ? stage : 'off';
77
+ }
78
+
79
+ /**
80
+ * @param {object} [config] — project runtime config (reads observability.*)
81
+ * @returns {{ recorder: string, projector: string, evaluation: string }}
82
+ */
83
+ export function resolveSeamStages(config = null) {
84
+ const out = {};
85
+ for (const seam of SEAM_NAMES) out[seam] = effectiveStage(config, seam);
86
+ return out;
87
+ }
88
+
89
+ /**
90
+ * Live per-seam config view for existing consumers that read
91
+ * `observability.stage` (createRecorder, projectSupport). The view's
92
+ * `observability` getter re-resolves on every access, so a kill switch or
93
+ * config edit is honored on the consumer's next call.
94
+ *
95
+ * @param {object} config
96
+ * @param {string} seam — one of SEAM_NAMES
97
+ * @returns {object} config view; `observability.stage` = effective seam stage
98
+ */
99
+ export function resolveSeamConfig(config, seam) {
100
+ if (!SEAM_NAMES.includes(seam)) {
101
+ throw new RangeError(`resolveSeamConfig: unknown seam ${JSON.stringify(seam)}`);
102
+ }
103
+ const source = isPlainObject(config) ? config : {};
104
+ const view = { ...source };
105
+ Object.defineProperty(view, 'observability', {
106
+ enumerable: true,
107
+ get() {
108
+ const base = isPlainObject(source.observability) ? source.observability : {};
109
+ return { ...base, stage: effectiveStage(source, seam) };
110
+ },
111
+ });
112
+ Object.defineProperty(view, BOUND, { value: { source, seam } });
113
+ return view;
114
+ }
115
+
116
+ /**
117
+ * Kill switch. `killSwitch(config)` disables every seam absolutely (global
118
+ * 'off' + all seam overrides pinned 'off'); `killSwitch(config, seam)`
119
+ * disables one seam. Mutates and returns the config. Passing a view from
120
+ * resolveSeamConfig redirects to its source config and bound seam.
121
+ *
122
+ * @param {object} config
123
+ * @param {string} [seam] — one of SEAM_NAMES; omit for a global kill
124
+ * @returns {object} the mutated config
125
+ */
126
+ export function killSwitch(config, seam) {
127
+ let target = config;
128
+ let targetSeam = seam;
129
+ if (isPlainObject(config) && config[BOUND]) {
130
+ target = config[BOUND].source;
131
+ targetSeam = seam === undefined ? config[BOUND].seam : seam;
132
+ }
133
+ if (!isPlainObject(target)) {
134
+ throw new TypeError('killSwitch: config must be a plain object');
135
+ }
136
+ if (targetSeam !== undefined && !SEAM_NAMES.includes(targetSeam)) {
137
+ throw new RangeError(`killSwitch: unknown seam ${JSON.stringify(targetSeam)}`);
138
+ }
139
+ if (!isPlainObject(target.observability)) target.observability = {};
140
+ if (targetSeam === undefined) {
141
+ target.observability.stage = 'off';
142
+ if (!isPlainObject(target.observability.seams)) target.observability.seams = {};
143
+ for (const name of SEAM_NAMES) {
144
+ if (!isPlainObject(target.observability.seams[name])) target.observability.seams[name] = {};
145
+ target.observability.seams[name].stage = 'off';
146
+ }
147
+ } else {
148
+ if (!isPlainObject(target.observability.seams)) target.observability.seams = {};
149
+ if (!isPlainObject(target.observability.seams[targetSeam])) {
150
+ target.observability.seams[targetSeam] = {};
151
+ }
152
+ target.observability.seams[targetSeam].stage = 'off';
153
+ }
154
+ return target;
155
+ }
@@ -0,0 +1,66 @@
1
+ // Semantic envelope constants — SPEC §5 DF-FR01/DF-FR02, §7, §8.
2
+ // Frozen for C54: additive changes only; a breaking semantic change requires a
3
+ // new SCHEMA_VERSION major, never an in-place edit of these vocabularies.
4
+
5
+ export const SCHEMA_VERSION = 1;
6
+
7
+ // DF-FR01 envelope. `origin` is the DF-FR02 provenance-layer discriminator:
8
+ // required on non-fact records, absent/optional on observed facts.
9
+ export const ENVELOPE_FIELDS = Object.freeze([
10
+ 'record_type',
11
+ 'semantic_name',
12
+ 'schema_version',
13
+ 'record_id',
14
+ 'trace_id',
15
+ 'span_id',
16
+ 'parent_span_id',
17
+ 'execution_id',
18
+ 'session_id',
19
+ 'project_ref',
20
+ 'boot_id',
21
+ 'writer_id',
22
+ 'sequence',
23
+ 'wall_time_utc',
24
+ 'monotonic_ns',
25
+ 'importance',
26
+ 'privacy_class',
27
+ 'origin',
28
+ 'payload',
29
+ ]);
30
+
31
+ export const REQUIRED_ENVELOPE_FIELDS = Object.freeze([
32
+ 'record_type',
33
+ 'semantic_name',
34
+ 'schema_version',
35
+ 'record_id',
36
+ 'boot_id',
37
+ 'writer_id',
38
+ 'sequence',
39
+ 'wall_time_utc',
40
+ 'importance',
41
+ 'privacy_class',
42
+ 'payload',
43
+ ]);
44
+
45
+ // DF-FR02 four provenance layers: observed fact / derived metric /
46
+ // evaluated judgment / optimization decision.
47
+ export const RECORD_TYPES = Object.freeze(['fact', 'derived', 'evaluation', 'decision']);
48
+
49
+ // Ordered least → most sensitive. `private` marks canonical private segments
50
+ // (append-only redacted facts) that never reach the support view.
51
+ export const PRIVACY_CLASSES = Object.freeze(['public', 'internal', 'sensitive', 'private']);
52
+
53
+ export const IMPORTANCE_LEVELS = Object.freeze(['low', 'normal', 'high', 'critical']);
54
+
55
+ // DF-FR08: resource usage provenance. Missing host measurements stay UNKNOWN,
56
+ // never 0.
57
+ export const RESOURCE_SOURCES = Object.freeze(['PROVIDER', 'LOCAL_TOKENIZER', 'ESTIMATED', 'UNKNOWN']);
58
+
59
+ // SPEC §7: confidence is always typed and never an instruction authority.
60
+ export const CONFIDENCE_TYPES = Object.freeze([
61
+ 'HEURISTIC',
62
+ 'STATISTICAL',
63
+ 'EVALUATOR',
64
+ 'CALIBRATED_MODEL',
65
+ 'MODEL_SELF_REPORT',
66
+ ]);