@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,223 @@
|
|
|
1
|
+
// Frozen semantic-name + reason-code registry — SPEC §5 DF-FR03, §7.
|
|
2
|
+
//
|
|
3
|
+
// Lifecycle: entries are append-only. A breaking semantic change (renamed
|
|
4
|
+
// meaning, changed unit, narrowed privacy) requires a NEW name or a new
|
|
5
|
+
// SCHEMA_VERSION major — never an in-place edit of an existing entry.
|
|
6
|
+
// `introduced_version` records the schema version that first defined the
|
|
7
|
+
// entry; the registry test asserts it on every entry so later additions are
|
|
8
|
+
// always new, dated entries.
|
|
9
|
+
//
|
|
10
|
+
// `privacy_class` is the default ceiling for the semantic name; a record's
|
|
11
|
+
// own `privacy_class` may raise it (e.g. a trace carrying user content) but
|
|
12
|
+
// sanitizers treat the registry value as the floor of scrutiny.
|
|
13
|
+
|
|
14
|
+
import { PRIVACY_CLASSES } from './constants.js';
|
|
15
|
+
|
|
16
|
+
const V1 = '1';
|
|
17
|
+
|
|
18
|
+
export const SEMANTIC_REGISTRY = Object.freeze({
|
|
19
|
+
'execution.started': {
|
|
20
|
+
definition: 'An execution unit (task/episode) began. Span root for the execution.',
|
|
21
|
+
privacy_class: 'internal',
|
|
22
|
+
retention_hint: 'retain',
|
|
23
|
+
introduced_version: V1,
|
|
24
|
+
},
|
|
25
|
+
'execution.completed': {
|
|
26
|
+
definition: 'An execution unit finished successfully. Payload carries outcome and duration.',
|
|
27
|
+
privacy_class: 'internal',
|
|
28
|
+
retention_hint: 'retain',
|
|
29
|
+
introduced_version: V1,
|
|
30
|
+
},
|
|
31
|
+
'execution.failed': {
|
|
32
|
+
definition: 'An execution unit terminated with failure. Payload carries error_code.',
|
|
33
|
+
privacy_class: 'internal',
|
|
34
|
+
retention_hint: 'retain',
|
|
35
|
+
introduced_version: V1,
|
|
36
|
+
},
|
|
37
|
+
'execution.blocked': {
|
|
38
|
+
definition: 'An execution unit stopped on an unmet precondition or gate.',
|
|
39
|
+
privacy_class: 'internal',
|
|
40
|
+
retention_hint: 'retain',
|
|
41
|
+
introduced_version: V1,
|
|
42
|
+
},
|
|
43
|
+
'tool.started': {
|
|
44
|
+
definition: 'A tool invocation began. Payload may name the tool; arguments stay redacted.',
|
|
45
|
+
privacy_class: 'sensitive',
|
|
46
|
+
retention_hint: 'retain',
|
|
47
|
+
introduced_version: V1,
|
|
48
|
+
},
|
|
49
|
+
'tool.completed': {
|
|
50
|
+
definition: 'A tool invocation finished. Payload carries duration; output stays redacted.',
|
|
51
|
+
privacy_class: 'sensitive',
|
|
52
|
+
retention_hint: 'retain',
|
|
53
|
+
introduced_version: V1,
|
|
54
|
+
},
|
|
55
|
+
'tool.failed': {
|
|
56
|
+
definition: 'A tool invocation failed. Payload carries error_code; stderr stays redacted.',
|
|
57
|
+
privacy_class: 'sensitive',
|
|
58
|
+
retention_hint: 'retain',
|
|
59
|
+
introduced_version: V1,
|
|
60
|
+
},
|
|
61
|
+
'model.started': {
|
|
62
|
+
definition: 'A model attempt began. Payload carries attempt_index.',
|
|
63
|
+
privacy_class: 'internal',
|
|
64
|
+
retention_hint: 'retain',
|
|
65
|
+
introduced_version: V1,
|
|
66
|
+
},
|
|
67
|
+
'model.completed': {
|
|
68
|
+
definition: 'A model attempt finished. Payload carries duration and typed resource usage.',
|
|
69
|
+
privacy_class: 'internal',
|
|
70
|
+
retention_hint: 'retain',
|
|
71
|
+
introduced_version: V1,
|
|
72
|
+
},
|
|
73
|
+
'model.failed': {
|
|
74
|
+
definition: 'A model attempt failed. Payload carries error_code and typed resource usage.',
|
|
75
|
+
privacy_class: 'internal',
|
|
76
|
+
retention_hint: 'retain',
|
|
77
|
+
introduced_version: V1,
|
|
78
|
+
},
|
|
79
|
+
'cache.hit': {
|
|
80
|
+
definition: 'A cache lookup resolved from cache. Payload identifies the cache seam, not content.',
|
|
81
|
+
privacy_class: 'internal',
|
|
82
|
+
retention_hint: 'aggregate',
|
|
83
|
+
introduced_version: V1,
|
|
84
|
+
},
|
|
85
|
+
'cache.miss': {
|
|
86
|
+
definition: 'A cache lookup did not resolve from cache.',
|
|
87
|
+
privacy_class: 'internal',
|
|
88
|
+
retention_hint: 'aggregate',
|
|
89
|
+
introduced_version: V1,
|
|
90
|
+
},
|
|
91
|
+
'verification.completed': {
|
|
92
|
+
definition: 'A verification step ran to completion. Payload carries the verdict, not raw output.',
|
|
93
|
+
privacy_class: 'internal',
|
|
94
|
+
retention_hint: 'retain',
|
|
95
|
+
introduced_version: V1,
|
|
96
|
+
},
|
|
97
|
+
'context.item.injected': {
|
|
98
|
+
definition: 'A context item (e.g. user correction) entered the episode. Kind only, never content.',
|
|
99
|
+
privacy_class: 'sensitive',
|
|
100
|
+
retention_hint: 'retain',
|
|
101
|
+
introduced_version: V1,
|
|
102
|
+
},
|
|
103
|
+
'decision.made': {
|
|
104
|
+
definition: 'An optimization or routing decision was recorded with experiment provenance.',
|
|
105
|
+
privacy_class: 'internal',
|
|
106
|
+
retention_hint: 'retain',
|
|
107
|
+
introduced_version: V1,
|
|
108
|
+
},
|
|
109
|
+
'telemetry.dropped': {
|
|
110
|
+
definition: 'Telemetry records were dropped or sampled. Payload carries reason_code and count.',
|
|
111
|
+
privacy_class: 'internal',
|
|
112
|
+
retention_hint: 'retain',
|
|
113
|
+
introduced_version: V1,
|
|
114
|
+
},
|
|
115
|
+
'telemetry.recovered': {
|
|
116
|
+
definition: 'Previously incomplete telemetry recovered (e.g. partial tail repaired).',
|
|
117
|
+
privacy_class: 'internal',
|
|
118
|
+
retention_hint: 'retain',
|
|
119
|
+
introduced_version: V1,
|
|
120
|
+
},
|
|
121
|
+
'telemetry.redacted': {
|
|
122
|
+
definition: 'A record or field was redacted before persistence. Payload carries the field path.',
|
|
123
|
+
privacy_class: 'internal',
|
|
124
|
+
retention_hint: 'retain',
|
|
125
|
+
introduced_version: V1,
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// Error/wait/reason vocabulary: codes + definitions, no free-text grouping.
|
|
130
|
+
// Used by payload.error_code, payload.reason_code and payload.wait_code.
|
|
131
|
+
export const REASON_CODES = Object.freeze({
|
|
132
|
+
TOOL_TIMEOUT: {
|
|
133
|
+
definition: 'A tool or model call exceeded its deadline.',
|
|
134
|
+
introduced_version: V1,
|
|
135
|
+
},
|
|
136
|
+
AUTH_FAILED: {
|
|
137
|
+
definition: 'Authentication or authorization was rejected by the provider.',
|
|
138
|
+
introduced_version: V1,
|
|
139
|
+
},
|
|
140
|
+
RATE_LIMITED: {
|
|
141
|
+
definition: 'The provider or host throttled the request.',
|
|
142
|
+
introduced_version: V1,
|
|
143
|
+
},
|
|
144
|
+
MODEL_ERROR: {
|
|
145
|
+
definition: 'The model endpoint returned an error response.',
|
|
146
|
+
introduced_version: V1,
|
|
147
|
+
},
|
|
148
|
+
NETWORK_ERROR: {
|
|
149
|
+
definition: 'A transport-level failure prevented the call.',
|
|
150
|
+
introduced_version: V1,
|
|
151
|
+
},
|
|
152
|
+
QUEUE_OVERFLOW: {
|
|
153
|
+
definition: 'The emit queue exceeded its bound; records were dropped.',
|
|
154
|
+
introduced_version: V1,
|
|
155
|
+
},
|
|
156
|
+
SAMPLED_OUT: {
|
|
157
|
+
definition: 'The record was excluded by the configured sampling policy.',
|
|
158
|
+
introduced_version: V1,
|
|
159
|
+
},
|
|
160
|
+
HOST_BLIND: {
|
|
161
|
+
definition: 'The host does not expose the field; value stays explicitly unknown.',
|
|
162
|
+
introduced_version: V1,
|
|
163
|
+
},
|
|
164
|
+
FLUSH_FAILED: {
|
|
165
|
+
definition: 'A recorder flush could not persist its batch; user task unaffected.',
|
|
166
|
+
introduced_version: V1,
|
|
167
|
+
},
|
|
168
|
+
DISK_FULL: {
|
|
169
|
+
definition: 'The storage volume rejected a write for lack of space.',
|
|
170
|
+
introduced_version: V1,
|
|
171
|
+
},
|
|
172
|
+
PERMISSION_DENIED: {
|
|
173
|
+
definition: 'The filesystem or OS denied the operation.',
|
|
174
|
+
introduced_version: V1,
|
|
175
|
+
},
|
|
176
|
+
SCHEMA_MISMATCH: {
|
|
177
|
+
definition: 'A record or bundle failed schema validation.',
|
|
178
|
+
introduced_version: V1,
|
|
179
|
+
},
|
|
180
|
+
CHECKSUM_MISMATCH: {
|
|
181
|
+
definition: 'A segment or bundle digest did not match its manifest.',
|
|
182
|
+
introduced_version: V1,
|
|
183
|
+
},
|
|
184
|
+
PARTIAL_TAIL: {
|
|
185
|
+
definition: 'A segment ended mid-record; the tail was truncated on recovery.',
|
|
186
|
+
introduced_version: V1,
|
|
187
|
+
},
|
|
188
|
+
ROTATED: {
|
|
189
|
+
definition: 'A segment was rotated out by retention or cap policy.',
|
|
190
|
+
introduced_version: V1,
|
|
191
|
+
},
|
|
192
|
+
REDACTED_FIELD: {
|
|
193
|
+
definition: 'A payload field was removed by the privacy gate before persistence.',
|
|
194
|
+
introduced_version: V1,
|
|
195
|
+
},
|
|
196
|
+
USER_CANCELLED: {
|
|
197
|
+
definition: 'The user aborted the operation.',
|
|
198
|
+
introduced_version: V1,
|
|
199
|
+
},
|
|
200
|
+
GATE_BLOCKED: {
|
|
201
|
+
definition: 'A completion or security gate stopped the execution.',
|
|
202
|
+
introduced_version: V1,
|
|
203
|
+
},
|
|
204
|
+
WAITING_ON_TOOL: {
|
|
205
|
+
definition: 'The span is waiting on a tool result.',
|
|
206
|
+
introduced_version: V1,
|
|
207
|
+
},
|
|
208
|
+
WAITING_ON_MODEL: {
|
|
209
|
+
definition: 'The span is waiting on a model response.',
|
|
210
|
+
introduced_version: V1,
|
|
211
|
+
},
|
|
212
|
+
WAITING_ON_USER: {
|
|
213
|
+
definition: 'The span is waiting on user input.',
|
|
214
|
+
introduced_version: V1,
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// Guard: registry privacy classes must stay inside the frozen vocabulary.
|
|
219
|
+
for (const [name, entry] of Object.entries(SEMANTIC_REGISTRY)) {
|
|
220
|
+
if (!PRIVACY_CLASSES.includes(entry.privacy_class)) {
|
|
221
|
+
throw new Error(`semantic registry entry ${name} has unknown privacy_class ${entry.privacy_class}`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// validateSemanticRecord — SPEC §8 contract.
|
|
2
|
+
//
|
|
3
|
+
// validateSemanticRecord(record, context?) → { ok: boolean, errors: string[] }
|
|
4
|
+
//
|
|
5
|
+
// Validates one semantic envelope against SCHEMA_VERSION 1. Never throws:
|
|
6
|
+
// malformed input returns { ok: false, errors }. Unknown optional fields are
|
|
7
|
+
// tolerated (DF-FR03); unknown REQUIRED-field values (schema_version,
|
|
8
|
+
// record_type, semantic_name, vocabularies) are rejected.
|
|
9
|
+
//
|
|
10
|
+
// `context` is optional cross-record state for stream validation:
|
|
11
|
+
// { knownSpanIds?: Set<string>, seenSequences?: Set<string> }
|
|
12
|
+
// knownSpanIds holds span_ids already observed in the trace; seenSequences
|
|
13
|
+
// holds `${boot_id}|${writer_id}|${sequence}` keys. The validator only READS
|
|
14
|
+
// context — callers add the record's own span/sequence after accepting it.
|
|
15
|
+
// Without context, per-record checks still run; cross-record checks are
|
|
16
|
+
// skipped rather than invented (the validator never fabricates causality).
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
SCHEMA_VERSION,
|
|
20
|
+
REQUIRED_ENVELOPE_FIELDS,
|
|
21
|
+
RECORD_TYPES,
|
|
22
|
+
PRIVACY_CLASSES,
|
|
23
|
+
IMPORTANCE_LEVELS,
|
|
24
|
+
RESOURCE_SOURCES,
|
|
25
|
+
CONFIDENCE_TYPES,
|
|
26
|
+
} from './constants.js';
|
|
27
|
+
import { SEMANTIC_REGISTRY, REASON_CODES } from './registry.js';
|
|
28
|
+
|
|
29
|
+
const OPTIONAL_ID_FIELDS = ['trace_id', 'span_id', 'parent_span_id', 'execution_id', 'session_id', 'project_ref'];
|
|
30
|
+
const REASON_CODE_FIELDS = ['error_code', 'reason_code', 'wait_code'];
|
|
31
|
+
const ISO_8601_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
|
|
32
|
+
|
|
33
|
+
function isPlainObject(value) {
|
|
34
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isNonEmptyString(value) {
|
|
38
|
+
return typeof value === 'string' && value.length > 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function validateOrigin(record, errors) {
|
|
42
|
+
const origin = record.origin;
|
|
43
|
+
switch (record.record_type) {
|
|
44
|
+
case 'fact':
|
|
45
|
+
// Observed facts carry no provenance origin; an origin field, if present,
|
|
46
|
+
// must still be an object so downstream readers can rely on its shape.
|
|
47
|
+
if (origin !== undefined && origin !== null && !isPlainObject(origin)) {
|
|
48
|
+
errors.push('origin must be an object when present');
|
|
49
|
+
}
|
|
50
|
+
return;
|
|
51
|
+
case 'derived':
|
|
52
|
+
if (!isPlainObject(origin)) {
|
|
53
|
+
errors.push('record_type "derived" requires origin with derived_from');
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (!Array.isArray(origin.derived_from) || origin.derived_from.length === 0) {
|
|
57
|
+
errors.push('origin.derived_from must be a non-empty array of source record refs');
|
|
58
|
+
}
|
|
59
|
+
return;
|
|
60
|
+
case 'evaluation':
|
|
61
|
+
if (!isPlainObject(origin)) {
|
|
62
|
+
errors.push('record_type "evaluation" requires origin with evaluator and confidence');
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (!isNonEmptyString(origin.evaluator)) {
|
|
66
|
+
errors.push('origin.evaluator must name the evaluator that produced the judgment');
|
|
67
|
+
}
|
|
68
|
+
if (!CONFIDENCE_TYPES.includes(origin.confidence)) {
|
|
69
|
+
errors.push(`origin.confidence must be one of ${CONFIDENCE_TYPES.join('|')} (got ${JSON.stringify(origin.confidence)})`);
|
|
70
|
+
}
|
|
71
|
+
return;
|
|
72
|
+
case 'decision':
|
|
73
|
+
if (!isPlainObject(origin)) {
|
|
74
|
+
errors.push('record_type "decision" requires origin with experiment provenance');
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (!isNonEmptyString(origin.experiment)) {
|
|
78
|
+
errors.push('origin.experiment must reference the experiment the decision belongs to');
|
|
79
|
+
}
|
|
80
|
+
return;
|
|
81
|
+
default:
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function validatePayload(payload, errors) {
|
|
87
|
+
if (payload.resource !== undefined && payload.resource !== null) {
|
|
88
|
+
const resource = payload.resource;
|
|
89
|
+
if (!isPlainObject(resource)) {
|
|
90
|
+
errors.push('payload.resource must be an object {value, source}');
|
|
91
|
+
} else {
|
|
92
|
+
if (!RESOURCE_SOURCES.includes(resource.source)) {
|
|
93
|
+
errors.push(`payload.resource.source must be one of ${RESOURCE_SOURCES.join('|')} (got ${JSON.stringify(resource.source)})`);
|
|
94
|
+
}
|
|
95
|
+
if (resource.value !== null && resource.value !== undefined && typeof resource.value !== 'number') {
|
|
96
|
+
errors.push('payload.resource.value must be a number or null (null only with source UNKNOWN)');
|
|
97
|
+
}
|
|
98
|
+
if (typeof resource.value === 'number' && resource.source === 'UNKNOWN') {
|
|
99
|
+
errors.push('payload.resource.value must be null when source is UNKNOWN — unknown is never 0');
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
for (const field of REASON_CODE_FIELDS) {
|
|
104
|
+
const code = payload[field];
|
|
105
|
+
if (code !== undefined && code !== null && !REASON_CODES[code]) {
|
|
106
|
+
errors.push(`payload.${field} ${JSON.stringify(code)} is not in the reason-code registry`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (payload.confidence !== undefined && !CONFIDENCE_TYPES.includes(payload.confidence)) {
|
|
110
|
+
errors.push(`payload.confidence must be one of ${CONFIDENCE_TYPES.join('|')}`);
|
|
111
|
+
}
|
|
112
|
+
if (payload.telemetry_complete !== undefined && typeof payload.telemetry_complete !== 'boolean') {
|
|
113
|
+
errors.push('payload.telemetry_complete must be a boolean');
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function validateSemanticRecord(record, context) {
|
|
118
|
+
const errors = [];
|
|
119
|
+
|
|
120
|
+
if (!isPlainObject(record)) {
|
|
121
|
+
return { ok: false, errors: ['record must be a plain object'] };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
for (const field of REQUIRED_ENVELOPE_FIELDS) {
|
|
125
|
+
if (record[field] === undefined || record[field] === null) {
|
|
126
|
+
errors.push(`missing required field: ${field}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
// Bail early on missing requireds — type checks below assume presence.
|
|
130
|
+
if (errors.length > 0) {
|
|
131
|
+
return { ok: false, errors };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!Number.isInteger(record.schema_version)) {
|
|
135
|
+
errors.push('schema_version must be an integer');
|
|
136
|
+
} else if (record.schema_version !== SCHEMA_VERSION) {
|
|
137
|
+
errors.push(`unsupported schema version: ${record.schema_version} (supported: ${SCHEMA_VERSION})`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (!RECORD_TYPES.includes(record.record_type)) {
|
|
141
|
+
errors.push(`record_type must be one of ${RECORD_TYPES.join('|')} (got ${JSON.stringify(record.record_type)})`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (!isNonEmptyString(record.semantic_name)) {
|
|
145
|
+
errors.push('semantic_name must be a non-empty string');
|
|
146
|
+
} else if (!SEMANTIC_REGISTRY[record.semantic_name]) {
|
|
147
|
+
errors.push(`unknown semantic name: ${record.semantic_name}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (!isNonEmptyString(record.record_id)) errors.push('record_id must be a non-empty string');
|
|
151
|
+
if (!isNonEmptyString(record.boot_id)) errors.push('boot_id must be a non-empty string');
|
|
152
|
+
if (!isNonEmptyString(record.writer_id)) errors.push('writer_id must be a non-empty string');
|
|
153
|
+
|
|
154
|
+
if (!Number.isInteger(record.sequence) || record.sequence < 0) {
|
|
155
|
+
errors.push('sequence must be a non-negative integer');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Strict ISO-8601 (RFC 3339 profile) — Date.parse alone accepts non-ISO
|
|
159
|
+
// strings like 'Jan 1, 2026', which would diverge from the JSON-schema
|
|
160
|
+
// mirror's `format: date-time`.
|
|
161
|
+
if (typeof record.wall_time_utc !== 'string'
|
|
162
|
+
|| !ISO_8601_RE.test(record.wall_time_utc)
|
|
163
|
+
|| Number.isNaN(Date.parse(record.wall_time_utc))) {
|
|
164
|
+
errors.push('wall_time_utc must be an ISO-8601 timestamp string');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (record.monotonic_ns !== undefined && record.monotonic_ns !== null) {
|
|
168
|
+
if (typeof record.monotonic_ns !== 'number' || !Number.isFinite(record.monotonic_ns) || record.monotonic_ns < 0) {
|
|
169
|
+
errors.push('monotonic_ns must be a non-negative number when present');
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (!IMPORTANCE_LEVELS.includes(record.importance)) {
|
|
174
|
+
errors.push(`importance must be one of ${IMPORTANCE_LEVELS.join('|')} (got ${JSON.stringify(record.importance)})`);
|
|
175
|
+
}
|
|
176
|
+
if (!PRIVACY_CLASSES.includes(record.privacy_class)) {
|
|
177
|
+
errors.push(`privacy_class must be one of ${PRIVACY_CLASSES.join('|')} (got ${JSON.stringify(record.privacy_class)})`);
|
|
178
|
+
} else {
|
|
179
|
+
// Registry privacy floor: a record may raise (restrict) its class above
|
|
180
|
+
// the registry entry's rank but never lower it — sanitizers treat the
|
|
181
|
+
// registry value as the floor of scrutiny.
|
|
182
|
+
const entry = SEMANTIC_REGISTRY[record.semantic_name];
|
|
183
|
+
if (entry && PRIVACY_CLASSES.indexOf(record.privacy_class) < PRIVACY_CLASSES.indexOf(entry.privacy_class)) {
|
|
184
|
+
errors.push(`privacy_class ${JSON.stringify(record.privacy_class)} is below the registry floor ${entry.privacy_class} for ${record.semantic_name}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
for (const field of OPTIONAL_ID_FIELDS) {
|
|
189
|
+
const value = record[field];
|
|
190
|
+
if (value !== undefined && value !== null && typeof value !== 'string') {
|
|
191
|
+
errors.push(`${field} must be a string or null`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (!isPlainObject(record.payload)) {
|
|
196
|
+
errors.push('payload must be an object');
|
|
197
|
+
} else {
|
|
198
|
+
validatePayload(record.payload, errors);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (RECORD_TYPES.includes(record.record_type)) {
|
|
202
|
+
validateOrigin(record, errors);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Cross-record checks — only when the caller supplies stream context.
|
|
206
|
+
if (context) {
|
|
207
|
+
const parent = record.parent_span_id;
|
|
208
|
+
if (typeof parent === 'string' && parent.length > 0) {
|
|
209
|
+
// A parent link is a causality claim: it needs monotonic evidence —
|
|
210
|
+
// wall_time_utc alone never orders records across writers/clocks.
|
|
211
|
+
if (record.monotonic_ns === undefined || record.monotonic_ns === null) {
|
|
212
|
+
errors.push('parent_span_id asserts causality but monotonic_ns is absent — wall time alone cannot order records');
|
|
213
|
+
}
|
|
214
|
+
if (context.knownSpanIds && !context.knownSpanIds.has(parent)) {
|
|
215
|
+
errors.push(`orphan parent_span_id: ${parent} not observed in this trace`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (context.seenSequences && Number.isInteger(record.sequence)) {
|
|
219
|
+
const key = `${record.boot_id}|${record.writer_id}|${record.sequence}`;
|
|
220
|
+
if (context.seenSequences.has(key)) {
|
|
221
|
+
errors.push(`duplicate sequence ${record.sequence} for writer ${record.writer_id} boot ${record.boot_id}`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return { ok: errors.length === 0, errors };
|
|
227
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* internal.js (TASK-006) — shared helpers for the segment store.
|
|
3
|
+
*
|
|
4
|
+
* Not part of the SPEC §8 public surface. Owns: on-disk layout constants,
|
|
5
|
+
* bounded-IO wrapper, path-safety resolution, state file, checksums, and
|
|
6
|
+
* segment enumeration. Public modules: readSegments.js, recovery.js,
|
|
7
|
+
* retention.js.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import crypto from 'node:crypto';
|
|
13
|
+
|
|
14
|
+
export const ACTIVE_FILE = 'active.jsonl';
|
|
15
|
+
export const STATE_FILE = '_state.json';
|
|
16
|
+
export const QUARANTINE_DIR = 'quarantine';
|
|
17
|
+
export const SEALED_RE = /^seg-(\d+)-([A-Za-z0-9_-]+)-([A-Za-z0-9_-]+)\.jsonl$/;
|
|
18
|
+
|
|
19
|
+
export const DEFAULT_IO_TIMEOUT_MS = 5000;
|
|
20
|
+
|
|
21
|
+
export const DEFAULT_POLICY = Object.freeze({
|
|
22
|
+
maxSegmentBytes: 8 * 1024 * 1024,
|
|
23
|
+
maxSegmentAgeMs: 24 * 60 * 60 * 1000,
|
|
24
|
+
maxTotalBytes: 256 * 1024 * 1024,
|
|
25
|
+
ioTimeoutMs: DEFAULT_IO_TIMEOUT_MS,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export function policyOf(policy) {
|
|
29
|
+
return { ...DEFAULT_POLICY, ...(policy || {}) };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function nowOf(policy) {
|
|
33
|
+
return policy && Number.isFinite(policy.now) ? policy.now : Date.now();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function ioTimeoutMsOf(policy) {
|
|
37
|
+
const v = policy && policy.ioTimeoutMs;
|
|
38
|
+
return Number.isFinite(v) ? v : DEFAULT_IO_TIMEOUT_MS;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Bounded IO: rejects with { code: 'IO_TIMEOUT' } after ms. ms <= 0 → immediate. */
|
|
42
|
+
export function withTimeout(promise, ms) {
|
|
43
|
+
if (ms <= 0) {
|
|
44
|
+
return Promise.reject(Object.assign(new Error('io timeout'), { code: 'IO_TIMEOUT' }));
|
|
45
|
+
}
|
|
46
|
+
let timer;
|
|
47
|
+
const timeout = new Promise((_, reject) => {
|
|
48
|
+
timer = setTimeout(() => {
|
|
49
|
+
reject(Object.assign(new Error('io timeout'), { code: 'IO_TIMEOUT' }));
|
|
50
|
+
}, ms);
|
|
51
|
+
if (timer.unref) timer.unref();
|
|
52
|
+
});
|
|
53
|
+
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Map an fs/timeout error to a static reason code. */
|
|
57
|
+
export function ioReason(err) {
|
|
58
|
+
if (err && err.code === 'IO_TIMEOUT') return 'io_timeout';
|
|
59
|
+
const code = err && err.code ? String(err.code).toLowerCase() : 'io_error';
|
|
60
|
+
return `io_${code}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** True when a raw path string carries a '..' segment. */
|
|
64
|
+
export function hasTraversal(rawPath) {
|
|
65
|
+
return String(rawPath).split(/[\\/]+/).includes('..');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Resolve a storage root safely.
|
|
70
|
+
* - rejects '..' segments outright (unsafe_root)
|
|
71
|
+
* - resolves symlinks; the real path must stay inside the real parent dir,
|
|
72
|
+
* so a symlinked root cannot escape its storage location
|
|
73
|
+
* - missing root is fine (exists:false) — callers create or treat as empty
|
|
74
|
+
*
|
|
75
|
+
* → { ok: true, root, exists } | { ok: false, reason }
|
|
76
|
+
*/
|
|
77
|
+
export async function resolveRoot(root, timeoutMs = DEFAULT_IO_TIMEOUT_MS) {
|
|
78
|
+
if (typeof root !== 'string' || root.length === 0) {
|
|
79
|
+
return { ok: false, reason: 'unsafe_root' };
|
|
80
|
+
}
|
|
81
|
+
if (hasTraversal(root)) {
|
|
82
|
+
return { ok: false, reason: 'unsafe_root' };
|
|
83
|
+
}
|
|
84
|
+
const resolved = path.resolve(root);
|
|
85
|
+
const parent = path.dirname(resolved);
|
|
86
|
+
let parentReal;
|
|
87
|
+
try {
|
|
88
|
+
parentReal = await withTimeout(fs.promises.realpath(parent), timeoutMs);
|
|
89
|
+
} catch (err) {
|
|
90
|
+
return { ok: false, reason: ioReason(err) };
|
|
91
|
+
}
|
|
92
|
+
let real;
|
|
93
|
+
try {
|
|
94
|
+
real = await withTimeout(fs.promises.realpath(resolved), timeoutMs);
|
|
95
|
+
} catch (err) {
|
|
96
|
+
if (err && err.code === 'ENOENT') {
|
|
97
|
+
return { ok: true, root: resolved, exists: false };
|
|
98
|
+
}
|
|
99
|
+
return { ok: false, reason: ioReason(err) };
|
|
100
|
+
}
|
|
101
|
+
// realpath canonicalizes the parent too (e.g. /var → /private/var on macOS),
|
|
102
|
+
// so containment is checked against the real parent, not the literal path.
|
|
103
|
+
if (real !== path.join(parentReal, path.basename(resolved))) {
|
|
104
|
+
return { ok: false, reason: 'unsafe_root' };
|
|
105
|
+
}
|
|
106
|
+
return { ok: true, root: real, exists: true };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Resolve an existing file inside its directory with the same containment
|
|
111
|
+
* rule as resolveRoot. → { ok: true, path } | { ok: false, reason }
|
|
112
|
+
*/
|
|
113
|
+
export async function resolveFileInDir(filePath, timeoutMs = DEFAULT_IO_TIMEOUT_MS) {
|
|
114
|
+
if (typeof filePath !== 'string' || filePath.length === 0 || hasTraversal(filePath)) {
|
|
115
|
+
return { ok: false, reason: 'unsafe_path' };
|
|
116
|
+
}
|
|
117
|
+
const resolved = path.resolve(filePath);
|
|
118
|
+
const parent = path.dirname(resolved);
|
|
119
|
+
let parentReal;
|
|
120
|
+
try {
|
|
121
|
+
parentReal = await withTimeout(fs.promises.realpath(parent), timeoutMs);
|
|
122
|
+
} catch (err) {
|
|
123
|
+
return { ok: false, reason: ioReason(err) };
|
|
124
|
+
}
|
|
125
|
+
let real;
|
|
126
|
+
try {
|
|
127
|
+
real = await withTimeout(fs.promises.realpath(resolved), timeoutMs);
|
|
128
|
+
} catch (err) {
|
|
129
|
+
if (err && err.code === 'ENOENT') return { ok: false, reason: 'not_found' };
|
|
130
|
+
return { ok: false, reason: ioReason(err) };
|
|
131
|
+
}
|
|
132
|
+
if (real !== path.join(parentReal, path.basename(resolved))) {
|
|
133
|
+
return { ok: false, reason: 'unsafe_path' };
|
|
134
|
+
}
|
|
135
|
+
return { ok: true, path: real };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function metaPathFor(segmentPath) {
|
|
139
|
+
return segmentPath.replace(/\.jsonl$/, '.meta.json');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function sha256Buffer(buf) {
|
|
143
|
+
return 'sha256:' + crypto.createHash('sha256').update(buf).digest('hex');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const STATE_DEFAULTS = Object.freeze({
|
|
147
|
+
next_segment_seq: 1,
|
|
148
|
+
expired_segments: 0,
|
|
149
|
+
quarantined_segments: 0,
|
|
150
|
+
disabled: false,
|
|
151
|
+
active_since_ms: null,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
/** Best-effort state read; corrupt/missing state degrades to defaults. */
|
|
155
|
+
export async function readState(root, timeoutMs = DEFAULT_IO_TIMEOUT_MS) {
|
|
156
|
+
try {
|
|
157
|
+
const raw = await withTimeout(fs.promises.readFile(path.join(root, STATE_FILE), 'utf8'), timeoutMs);
|
|
158
|
+
const parsed = JSON.parse(raw);
|
|
159
|
+
return { ...STATE_DEFAULTS, ...(parsed && typeof parsed === 'object' ? parsed : {}) };
|
|
160
|
+
} catch {
|
|
161
|
+
return { ...STATE_DEFAULTS };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function writeState(root, state, timeoutMs = DEFAULT_IO_TIMEOUT_MS) {
|
|
166
|
+
try {
|
|
167
|
+
await withTimeout(
|
|
168
|
+
fs.promises.writeFile(path.join(root, STATE_FILE), JSON.stringify(state, null, 2)),
|
|
169
|
+
timeoutMs,
|
|
170
|
+
);
|
|
171
|
+
return true;
|
|
172
|
+
} catch {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function sanitizeNameComponent(value) {
|
|
178
|
+
const s = typeof value === 'string' && value.length > 0 ? value : 'unknown';
|
|
179
|
+
return s.replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 64) || 'unknown';
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Enumerate the segment directory.
|
|
184
|
+
* → { ok: true, sealed: [{ name, seq, path, size }], active: { path, size } | null,
|
|
185
|
+
* skipped: [{ name, reason }] }
|
|
186
|
+
* | { ok: false, reason }
|
|
187
|
+
* Symlinks are never followed — reported as skipped. Foreign .jsonl files that
|
|
188
|
+
* match neither the active nor sealed naming scheme are skipped, never read.
|
|
189
|
+
*/
|
|
190
|
+
export async function listSegments(root, timeoutMs = DEFAULT_IO_TIMEOUT_MS) {
|
|
191
|
+
let entries;
|
|
192
|
+
try {
|
|
193
|
+
entries = await withTimeout(fs.promises.readdir(root, { withFileTypes: true }), timeoutMs);
|
|
194
|
+
} catch (err) {
|
|
195
|
+
if (err && err.code === 'ENOENT') return { ok: true, sealed: [], active: null, skipped: [] };
|
|
196
|
+
return { ok: false, reason: ioReason(err) };
|
|
197
|
+
}
|
|
198
|
+
const sealed = [];
|
|
199
|
+
const skipped = [];
|
|
200
|
+
let active = null;
|
|
201
|
+
for (const entry of entries) {
|
|
202
|
+
const name = entry.name;
|
|
203
|
+
if (name === QUARANTINE_DIR || name === STATE_FILE || name.endsWith('.meta.json')) continue;
|
|
204
|
+
if (!name.endsWith('.jsonl')) continue;
|
|
205
|
+
if (entry.isSymbolicLink()) {
|
|
206
|
+
skipped.push({ name, reason: 'symlink_skipped' });
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (!entry.isFile()) {
|
|
210
|
+
skipped.push({ name, reason: 'not_regular_file' });
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const full = path.join(root, name);
|
|
214
|
+
let size = 0;
|
|
215
|
+
try {
|
|
216
|
+
size = (await withTimeout(fs.promises.stat(full), timeoutMs)).size;
|
|
217
|
+
} catch {
|
|
218
|
+
skipped.push({ name, reason: 'stat_failed' });
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
if (name === ACTIVE_FILE) {
|
|
222
|
+
active = { path: full, size };
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
const m = SEALED_RE.exec(name);
|
|
226
|
+
if (m) {
|
|
227
|
+
sealed.push({ name, seq: Number.parseInt(m[1], 10), path: full, size });
|
|
228
|
+
} else {
|
|
229
|
+
skipped.push({ name, reason: 'unrecognized_segment_name' });
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
sealed.sort((a, b) => a.seq - b.seq || a.name.localeCompare(b.name));
|
|
233
|
+
return { ok: true, sealed, active, skipped };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Total bytes held in live .jsonl segment files (meta/state excluded). */
|
|
237
|
+
export function totalSegmentBytes(listing) {
|
|
238
|
+
let total = listing.active ? listing.active.size : 0;
|
|
239
|
+
for (const seg of listing.sealed) total += seg.size;
|
|
240
|
+
return total;
|
|
241
|
+
}
|