@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.
- package/CHANGELOG.md +16 -0
- package/package.json +1 -1
- package/scripts/bench/data-foundation.mjs +562 -0
- package/src/core/observability/adapters/common.js +75 -0
- package/src/core/observability/adapters/contextAdapter.js +55 -0
- package/src/core/observability/adapters/decisionAdapter.js +61 -0
- package/src/core/observability/adapters/routeAdapter.js +135 -0
- package/src/core/observability/analytics/digest.js +186 -0
- package/src/core/observability/analytics/fingerprints.js +126 -0
- package/src/core/observability/analytics/opportunities.js +329 -0
- package/src/core/observability/analytics/rebuild.js +56 -0
- package/src/core/observability/analytics/summary.js +298 -0
- package/src/core/observability/emit/config.js +29 -0
- package/src/core/observability/emit/recorder.js +297 -0
- package/src/core/observability/evaluation/aiPacket.js +230 -0
- package/src/core/observability/evaluation/optimizationKnowledge.js +172 -0
- package/src/core/observability/evaluation/replay.js +143 -0
- package/src/core/observability/evaluation/scorecard.js +445 -0
- package/src/core/observability/privacy/allowlist.js +185 -0
- package/src/core/observability/privacy/redaction.js +113 -0
- package/src/core/observability/privacy/sanitizeForSupport.js +133 -0
- package/src/core/observability/privacy/sanitizeObserved.js +134 -0
- package/src/core/observability/rollout.js +155 -0
- package/src/core/observability/schema/constants.js +66 -0
- package/src/core/observability/schema/registry.js +223 -0
- package/src/core/observability/schema/validate.js +227 -0
- package/src/core/observability/segments/internal.js +241 -0
- package/src/core/observability/segments/readSegments.js +215 -0
- package/src/core/observability/segments/recovery.js +123 -0
- package/src/core/observability/segments/retention.js +381 -0
- package/src/core/observability/support/import.js +402 -0
- package/src/core/observability/support/manifest.js +135 -0
- package/src/core/observability/support/paths.js +94 -0
- package/src/core/observability/support/projector.js +483 -0
- package/src/core/observability/support/renderer.js +130 -0
- package/src/core/observability/support/retention.js +155 -0
- package/template_project/.omp/RULES.md +6 -6
- package/template_project/.omp/config.yml +6 -0
- 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
|
+
]);
|