@ngockhoale/ukit 3.4.23 → 3.4.24
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 +10 -0
- package/package.json +1 -1
- package/src/cli/commands/telemetry.js +67 -4
- package/src/cli/index.js +1 -1
- package/src/core/observability/adapters/routeAdapter.js +48 -9
- package/src/core/observability/analytics/usageReadiness.js +342 -0
- package/src/core/observability/privacy/allowlist.js +10 -1
- package/src/core/observability/schema/constants.js +13 -0
- package/src/core/observability/schema/registry.js +20 -0
- package/src/core/observability/schema/validate.js +45 -2
- package/src/core/observability/segments/readSegments.js +23 -1
- package/template_project/.claude/ukit/runtime/observability-emit.mjs +53 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to UKit are documented here.
|
|
4
4
|
|
|
5
|
+
## 3.4.24 - 2026-10-08
|
|
6
|
+
|
|
7
|
+
**Honest per-category token usage record + report** (C98; capability only — no producer exists yet, so real stores report `no_data`).
|
|
8
|
+
- Schema v1.3 (additive): `model.completed`/`model.failed` payloads may now carry `usage_categories` — the five typed `{value, source}` resources `fresh_input_tokens`, `cache_read_tokens`, `cache_write_tokens`, `output_tokens`, `reasoning_tokens`. Absent categories stay `UNKNOWN`/null — never 0, never estimated. The registry stays append-only; old records remain valid.
|
|
9
|
+
- `routeAdapter` maps provider-reported categories when a producer supplies `usage.categories`; numeric values are accepted only from `PROVIDER` sources, and the legacy aggregate-token mapping is byte-unchanged.
|
|
10
|
+
- New read-only `ukit telemetry usage [--json]`: scans the segment store non-destructively (corrupt segments are skipped and reported, never quarantined by a read), prints a readiness verdict `no_data | aggregate_only | categorized`, per-source totals only (never combined), record counts (not unique runs), and labels `cache.hit/miss` as UKit-internal lookups, not a provider prompt cache. Exit codes: 0 normal, 1 usage error, 2 typed store failure. Creates no files.
|
|
11
|
+
- `readSegments(..., {readOnly: true})` new option: default quarantine behavior is byte-for-byte unchanged.
|
|
12
|
+
- Privacy: the five category keys + `usage_categories` joined the support-projection allowlist and `REDACTION_VERSION` bumped to `df-redact-7` (existing support bundles invalidate once). Counts and source tags only — never content.
|
|
13
|
+
- Not included: a producer that actually records provider token categories (queued Q-C98-1/Q-C98-2 — needs evidence the host exposes usage), any cost/price model or provider identity fields (Q-C98-3). No token-savings or cost claims are made or implied.
|
|
14
|
+
|
|
5
15
|
## 3.4.23 - 2026-10-08
|
|
6
16
|
|
|
7
17
|
**Core hook/gate reliability repairs** (C97; each backed by baseline RED, GREEN and independent different-model review).
|
package/package.json
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
//
|
|
4
4
|
// collect ingestStoredTelemetry → flush → maybeRefreshSupport (canary+)
|
|
5
5
|
// status [--json] stage, segments, counters, support lag, crashes
|
|
6
|
+
// usage [--json] read-only usage-readiness report over the segment store
|
|
6
7
|
// digest [--json] rebuildIndex → anomalies + digest markdown
|
|
7
8
|
// export-support projectSupport now (explicit user act — works below canary)
|
|
8
9
|
// import <path> validateSupportBundle on a received bundle
|
|
@@ -29,6 +30,10 @@ import { ingestStoredTelemetry } from '../../core/observability/adapters/ingest.
|
|
|
29
30
|
import { listSegments, totalSegmentBytes } from '../../core/observability/segments/internal.js';
|
|
30
31
|
import { readSegments } from '../../core/observability/segments/readSegments.js';
|
|
31
32
|
import { rebuildIndex } from '../../core/observability/analytics/rebuild.js';
|
|
33
|
+
import {
|
|
34
|
+
buildUsageReadiness,
|
|
35
|
+
renderUsageReadiness,
|
|
36
|
+
} from '../../core/observability/analytics/usageReadiness.js';
|
|
32
37
|
import { renderStoreDigest } from '../../core/observability/analytics/storeDigest.js';
|
|
33
38
|
import { renderTraceDigest } from '../../core/observability/analytics/digest.js';
|
|
34
39
|
import {
|
|
@@ -43,8 +48,8 @@ import { isValidOperationId } from '../../core/agentRuntime/eventStore.js';
|
|
|
43
48
|
import { runEvaluation } from '../../core/observability/evaluation/runner.js';
|
|
44
49
|
|
|
45
50
|
const HELP_FLAGS = new Set(['--help', '-h', 'help']);
|
|
46
|
-
const SUBCOMMANDS = new Set(['collect', 'status', 'digest', 'export-support', 'export-run', 'import', 'evaluate']);
|
|
47
|
-
const JSON_SUBCOMMANDS = new Set(['status', 'digest', 'evaluate']);
|
|
51
|
+
const SUBCOMMANDS = new Set(['collect', 'status', 'usage', 'digest', 'export-support', 'export-run', 'import', 'evaluate']);
|
|
52
|
+
const JSON_SUBCOMMANDS = new Set(['status', 'usage', 'digest', 'evaluate']);
|
|
48
53
|
|
|
49
54
|
// The automatic support refresh's canary gate lives in schedule.js via
|
|
50
55
|
// resolveSeamStages (projector seam minimum) — the CLI does not pre-gate.
|
|
@@ -61,6 +66,7 @@ function printUsage() {
|
|
|
61
66
|
console.log(' collect Ingest stored telemetry into the flight recorder,');
|
|
62
67
|
console.log(' flush it, and refresh the support view (stage canary+)');
|
|
63
68
|
console.log(' status Show stage, segments, counters, support lag, crashes');
|
|
69
|
+
console.log(' usage Read-only usage readiness report over the segment store');
|
|
64
70
|
console.log(' digest Rebuild the trace index; print anomalies + digest markdown');
|
|
65
71
|
console.log(' export-support Write the sanitized UKit Support bundle now');
|
|
66
72
|
console.log(' export-run <op> Write a sanitized per-run support bundle (V-06)');
|
|
@@ -68,7 +74,7 @@ function printUsage() {
|
|
|
68
74
|
console.log(' evaluate Run the AI evaluator lane (needs observability.evaluator config)');
|
|
69
75
|
console.log('');
|
|
70
76
|
console.log('Options:');
|
|
71
|
-
console.log(' --json JSON output (status, digest, evaluate)');
|
|
77
|
+
console.log(' --json JSON output (status, usage, digest, evaluate)');
|
|
72
78
|
console.log(' -h, --help Show this help');
|
|
73
79
|
}
|
|
74
80
|
|
|
@@ -337,6 +343,59 @@ async function digestCommand({ projectRoot, json }) {
|
|
|
337
343
|
console.log(out.join('\n'));
|
|
338
344
|
}
|
|
339
345
|
|
|
346
|
+
// --- usage --------------------------------------------------------------------
|
|
347
|
+
|
|
348
|
+
// Scan cap (SPEC FR-07): at most 200,000 records feed the report; the reader
|
|
349
|
+
// limit is one higher so a 200001st record can mark the scan as truncated.
|
|
350
|
+
const USAGE_SCAN_LIMIT = 200_000;
|
|
351
|
+
|
|
352
|
+
// Read-only readiness scan (TASK-C98-006, SPEC §5 FR-07): never getRecorder,
|
|
353
|
+
// never a flush, never mkdir, never a quarantine — the store looks after
|
|
354
|
+
// `usage` exactly as it looked before. Dispatched before loadConfig so the
|
|
355
|
+
// report cannot create a runtime config either.
|
|
356
|
+
async function usageCommand({ projectRoot, json }) {
|
|
357
|
+
const root = segmentsRoot(projectRoot);
|
|
358
|
+
const stream = readSegments(root, { readOnly: true, limit: USAGE_SCAN_LIMIT + 1 });
|
|
359
|
+
const records = [];
|
|
360
|
+
let truncated = false;
|
|
361
|
+
for await (const record of stream) {
|
|
362
|
+
if (records.length === USAGE_SCAN_LIMIT) {
|
|
363
|
+
truncated = true;
|
|
364
|
+
break;
|
|
365
|
+
}
|
|
366
|
+
records.push(record);
|
|
367
|
+
}
|
|
368
|
+
const report = buildUsageReadiness(records, {
|
|
369
|
+
coverage: stream.coverage,
|
|
370
|
+
truncated,
|
|
371
|
+
scanLimit: USAGE_SCAN_LIMIT,
|
|
372
|
+
});
|
|
373
|
+
if (json) console.log(JSON.stringify(report));
|
|
374
|
+
else console.log(renderUsageReadiness(report));
|
|
375
|
+
|
|
376
|
+
// Segment-level degradation stays exit 0 — the report itself declares the
|
|
377
|
+
// gap (unquarantined segments, degraded entries). Only a root-level degrade
|
|
378
|
+
// (the segments root itself unreadable, e.g. io_enotdir on a file at that
|
|
379
|
+
// path) is this command's typed failure; the resolveRoot io_enoent on a
|
|
380
|
+
// not-yet-existing tree is the missing-store success, not a failure
|
|
381
|
+
// (SPEC FR-07). resolveRoot reports the realpath'd root (macOS /var →
|
|
382
|
+
// /private/var), so compare against both spellings.
|
|
383
|
+
const degraded = Array.isArray(stream.coverage.degraded) ? stream.coverage.degraded : [];
|
|
384
|
+
let realRoot = root;
|
|
385
|
+
try {
|
|
386
|
+
realRoot = await fs.realpath(root);
|
|
387
|
+
} catch {
|
|
388
|
+
// Root absent (empty store) — the literal spelling is the only one.
|
|
389
|
+
}
|
|
390
|
+
const rootDegrade = degraded.find(
|
|
391
|
+
(e) => e && (e.file === root || e.file === realRoot) && e.reason !== 'io_enoent',
|
|
392
|
+
);
|
|
393
|
+
if (rootDegrade) {
|
|
394
|
+
console.error(`[UKit] telemetry usage failed: ${rootDegrade.reason}`);
|
|
395
|
+
process.exitCode = 2;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
340
399
|
// --- export-support / import --------------------------------------------------
|
|
341
400
|
|
|
342
401
|
async function exportSupportCommand({ projectRoot, config }) {
|
|
@@ -506,9 +565,13 @@ export async function runTelemetry({ projectRoot, packageRoot, argv = [] }) {
|
|
|
506
565
|
return;
|
|
507
566
|
}
|
|
508
567
|
|
|
509
|
-
const config = await loadConfig(projectRoot);
|
|
510
568
|
const json = flags.includes('--json');
|
|
511
569
|
|
|
570
|
+
// Read-only scan — config is not needed and must not be created (FR-07).
|
|
571
|
+
if (sub === 'usage') return usageCommand({ projectRoot, json });
|
|
572
|
+
|
|
573
|
+
const config = await loadConfig(projectRoot);
|
|
574
|
+
|
|
512
575
|
if (sub === 'collect') return collectCommand({ projectRoot, config });
|
|
513
576
|
if (sub === 'status') return statusCommand({ projectRoot, config, json });
|
|
514
577
|
if (sub === 'digest') return digestCommand({ projectRoot, json });
|
package/src/cli/index.js
CHANGED
|
@@ -141,7 +141,7 @@ export async function runCli({ argv, packageRoot, projectRoot, packageVersion })
|
|
|
141
141
|
console.log(' memory Inspect shared UKit memory');
|
|
142
142
|
console.log(' playbook List/show playbooks (project > user > builtin)');
|
|
143
143
|
console.log(' metrics Telemetry roll-up (route outcomes, failure patterns, memory)');
|
|
144
|
-
console.log(' telemetry Flight recorder (collect/status/digest/export-support/import/evaluate)');
|
|
144
|
+
console.log(' telemetry Flight recorder (collect/status/usage/digest/export-support/import/evaluate)');
|
|
145
145
|
console.log(' feedback Record or list wrong-route feedback labels');
|
|
146
146
|
console.log(' self-improve Run the automatic learn/tune/collect pass now (hooks run it)');
|
|
147
147
|
console.log(' vm Agent VM driver (list/show/status/shadow-run/evidence)');
|
|
@@ -29,9 +29,22 @@ import {
|
|
|
29
29
|
createAdapterContext,
|
|
30
30
|
emitRecord,
|
|
31
31
|
} from './common.js';
|
|
32
|
+
import { USAGE_CATEGORY_KEYS, USAGE_CATEGORIES_FIELD } from '../schema/constants.js';
|
|
32
33
|
|
|
33
34
|
const RESOURCE_SOURCES = new Set(['PROVIDER', 'LOCAL_TOKENIZER', 'ESTIMATED', 'UNKNOWN']);
|
|
34
35
|
|
|
36
|
+
// C98 FR-05 producer contract (SPEC §5): `ledger.model.usage.categories`
|
|
37
|
+
// carries camelCase counts normalized by the producer (freshInput/cacheRead/
|
|
38
|
+
// cacheWrite/output/reasoning); the adapter maps them to the schema category
|
|
39
|
+
// keys without interpreting provider-native names.
|
|
40
|
+
const USAGE_CATEGORY_INPUT_KEYS = Object.freeze({
|
|
41
|
+
freshInput: 'fresh_input_tokens',
|
|
42
|
+
cacheRead: 'cache_read_tokens',
|
|
43
|
+
cacheWrite: 'cache_write_tokens',
|
|
44
|
+
output: 'output_tokens',
|
|
45
|
+
reasoning: 'reasoning_tokens',
|
|
46
|
+
});
|
|
47
|
+
|
|
35
48
|
function normalizeRow(row) {
|
|
36
49
|
if (!isPlainObject(row)) return null;
|
|
37
50
|
const audit = isPlainObject(row.audit) ? row.audit : row;
|
|
@@ -79,6 +92,29 @@ function modelResource(model) {
|
|
|
79
92
|
return { value, source };
|
|
80
93
|
}
|
|
81
94
|
|
|
95
|
+
// C98 FR-05: categories are provider-reported or unknown. A numeric value is
|
|
96
|
+
// accepted only when usage.source === 'PROVIDER'; any other source (incl.
|
|
97
|
+
// missing/UNKNOWN/ESTIMATED/LOCAL_TOKENIZER), absent key, or non-integer
|
|
98
|
+
// value stays `{ value: null, source: 'UNKNOWN' }` — never 0, never derived
|
|
99
|
+
// from the aggregate, never estimated. Returns null when no categories
|
|
100
|
+
// object is supplied, so the payload field is omitted entirely.
|
|
101
|
+
function usageCategories(model) {
|
|
102
|
+
const usage = isPlainObject(model.usage) ? model.usage : {};
|
|
103
|
+
if (!isPlainObject(usage.categories)) return null;
|
|
104
|
+
const categories = {};
|
|
105
|
+
for (const key of USAGE_CATEGORY_KEYS) {
|
|
106
|
+
categories[key] = { value: null, source: 'UNKNOWN' };
|
|
107
|
+
}
|
|
108
|
+
if (usage.source !== 'PROVIDER') return categories;
|
|
109
|
+
for (const [inputKey, categoryKey] of Object.entries(USAGE_CATEGORY_INPUT_KEYS)) {
|
|
110
|
+
const value = usage.categories[inputKey];
|
|
111
|
+
if (typeof value === 'number' && Number.isInteger(value) && value >= 0) {
|
|
112
|
+
categories[categoryKey] = { value, source: 'PROVIDER' };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return categories;
|
|
116
|
+
}
|
|
117
|
+
|
|
82
118
|
export function adaptRouteOutcomes(rows, options = {}) {
|
|
83
119
|
if (!Array.isArray(rows)) return [];
|
|
84
120
|
const ctx = createAdapterContext({ writerId: 'adapter-route', ...options });
|
|
@@ -113,19 +149,22 @@ export function adaptRouteOutcomes(rows, options = {}) {
|
|
|
113
149
|
const model = isPlainObject(ledger.model) ? ledger.model : null;
|
|
114
150
|
if (model) {
|
|
115
151
|
const resource = modelResource(model);
|
|
152
|
+
const categories = usageCategories(model);
|
|
116
153
|
const failed = model.failed === true || asString(model.status) === 'failed';
|
|
154
|
+
const modelPayload = {
|
|
155
|
+
request_key: requestKey,
|
|
156
|
+
duration_ms:
|
|
157
|
+
typeof model.durationMs === 'number' && Number.isFinite(model.durationMs)
|
|
158
|
+
? model.durationMs
|
|
159
|
+
: null,
|
|
160
|
+
resource,
|
|
161
|
+
telemetry_complete: resource.source !== 'UNKNOWN',
|
|
162
|
+
};
|
|
163
|
+
if (categories) modelPayload[USAGE_CATEGORIES_FIELD] = categories;
|
|
117
164
|
const modelRecord = emitRecord(ctx, {
|
|
118
165
|
semantic_name: failed ? 'model.failed' : 'model.completed',
|
|
119
166
|
wall_time_utc: asIsoTime(audit.ts) ?? asIsoTime(ledger.updatedAt),
|
|
120
|
-
payload:
|
|
121
|
-
request_key: requestKey,
|
|
122
|
-
duration_ms:
|
|
123
|
-
typeof model.durationMs === 'number' && Number.isFinite(model.durationMs)
|
|
124
|
-
? model.durationMs
|
|
125
|
-
: null,
|
|
126
|
-
resource,
|
|
127
|
-
telemetry_complete: resource.source !== 'UNKNOWN',
|
|
128
|
-
},
|
|
167
|
+
payload: modelPayload,
|
|
129
168
|
});
|
|
130
169
|
if (modelRecord) records.push(modelRecord);
|
|
131
170
|
}
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* usageReadiness.js (TASK-C98-005, SPEC §5 FR-06 / §9 / §10) — pure, deterministic
|
|
3
|
+
* usage-readiness report.
|
|
4
|
+
*
|
|
5
|
+
* buildUsageReadiness(records, options): UsageReport
|
|
6
|
+
* renderUsageReadiness(report): string
|
|
7
|
+
*
|
|
8
|
+
* Pure like analytics/summary.js: no IO, no clock, no randomness — the same
|
|
9
|
+
* records in any order always produce a byte-identical report, and the input
|
|
10
|
+
* is never mutated.
|
|
11
|
+
*
|
|
12
|
+
* Honesty rules (SPEC §1/§5; docs/OBSERVABILITY.md):
|
|
13
|
+
* - Totals are PER SOURCE and PER FAMILY only. Sources are never summed
|
|
14
|
+
* together, and model vs subagent usage is never combined (a subagent
|
|
15
|
+
* run may include model usage — summing would double-count).
|
|
16
|
+
* - A total is null when no numeric value was recorded for that source:
|
|
17
|
+
* unknown stays unknown, never 0. A provider-reported 0 stays 0.
|
|
18
|
+
* - Record counts count records, not unique runs (ingest may duplicate).
|
|
19
|
+
* - cache.hit/cache.miss are UKit's own lookup cache, never a provider
|
|
20
|
+
* prompt cache.
|
|
21
|
+
* - ESTIMATED / LOCAL_TOKENIZER / UNKNOWN evidence never upgrades the
|
|
22
|
+
* verdict to aggregate_only/categorized.
|
|
23
|
+
*
|
|
24
|
+
* The five category key names are duplicated locally (SPEC FR-02) so wave-1
|
|
25
|
+
* tasks stay file-disjoint; TASK-C98-006 guards the drift by feeding a
|
|
26
|
+
* validator-accepted fixture through this report.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export const USAGE_REPORT_VERSION = 'df-usage-1';
|
|
30
|
+
|
|
31
|
+
// Local frozen copy of SPEC FR-02's USAGE_CATEGORY_KEYS (wave-1 parallelism).
|
|
32
|
+
const USAGE_CATEGORY_KEYS = Object.freeze([
|
|
33
|
+
'fresh_input_tokens',
|
|
34
|
+
'cache_read_tokens',
|
|
35
|
+
'cache_write_tokens',
|
|
36
|
+
'output_tokens',
|
|
37
|
+
'reasoning_tokens',
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
const SOURCE_KEYS = Object.freeze(['PROVIDER', 'LOCAL_TOKENIZER', 'ESTIMATED', 'UNKNOWN']);
|
|
41
|
+
|
|
42
|
+
const INTERNAL_CACHE_SCOPE = 'ukit_internal_lookup_not_provider_prompt_cache';
|
|
43
|
+
|
|
44
|
+
const MAX_DEGRADED_DEFAULT = 20;
|
|
45
|
+
const MAX_STRING_LENGTH = 200;
|
|
46
|
+
|
|
47
|
+
// Fixed canonical gap order (SPEC FR-06); membership is data-driven below.
|
|
48
|
+
const GAP_NO_MODEL_RECORDS = 'no_model_records';
|
|
49
|
+
const GAP_NO_PROVIDER_AGGREGATE = 'no_provider_aggregate';
|
|
50
|
+
const GAP_NO_PROVIDER_CATEGORIES = 'no_provider_categories';
|
|
51
|
+
const GAP_CATEGORIES_PARTIAL = 'categories_partial';
|
|
52
|
+
const GAP_SUBAGENT_USAGE_ABSENT = 'subagent_usage_absent';
|
|
53
|
+
const GAP_PROVIDER_IDENTITY = 'provider_identity_not_recorded';
|
|
54
|
+
const GAP_PROMPT_CACHE = 'prompt_cache_not_provider_reported';
|
|
55
|
+
const GAP_STORE_COVERAGE = 'store_coverage_degraded';
|
|
56
|
+
const GAP_SCAN_TRUNCATED = 'scan_truncated';
|
|
57
|
+
|
|
58
|
+
const NOTES = Object.freeze([
|
|
59
|
+
'Record counts are record counts, not unique runs (ingest may duplicate records).',
|
|
60
|
+
'cache.hit/cache.miss are UKit internal cache lookups, not a provider prompt cache.',
|
|
61
|
+
'Estimated values are never reported as provider values.',
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
function isPlainObject(value) {
|
|
65
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function isNumeric(value) {
|
|
69
|
+
return typeof value === 'number' && Number.isFinite(value);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function truncateString(value) {
|
|
73
|
+
return typeof value === 'string' && value.length > MAX_STRING_LENGTH
|
|
74
|
+
? value.slice(0, MAX_STRING_LENGTH)
|
|
75
|
+
: value;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function emptyBySource() {
|
|
79
|
+
const bySource = {};
|
|
80
|
+
for (const source of SOURCE_KEYS) {
|
|
81
|
+
bySource[source] = { records: 0, total: null };
|
|
82
|
+
}
|
|
83
|
+
return bySource;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Bucket one {value, source} entry. UNKNOWN totals stay null by shape. */
|
|
87
|
+
function addUsageEntry(bySource, flags, entry) {
|
|
88
|
+
const source = isPlainObject(entry) && typeof entry.source === 'string' ? entry.source : null;
|
|
89
|
+
// Own-property guard: an inherited name like 'toString' must never resolve
|
|
90
|
+
// to a prototype member (R-A finding — purity/pollution).
|
|
91
|
+
const bucket = source === null || !Object.hasOwn(bySource, source)
|
|
92
|
+
? null
|
|
93
|
+
: bySource[source];
|
|
94
|
+
if (!bucket) return;
|
|
95
|
+
bucket.records += 1;
|
|
96
|
+
const numeric = isNumeric(entry.value);
|
|
97
|
+
if (!numeric) return;
|
|
98
|
+
if (source === 'PROVIDER') flags.providerAggregate = true;
|
|
99
|
+
else if (source === 'ESTIMATED' || source === 'LOCAL_TOKENIZER') flags.numericNonProvider = true;
|
|
100
|
+
if (source !== 'UNKNOWN') {
|
|
101
|
+
bucket.total = (bucket.total === null ? 0 : bucket.total) + entry.value;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Capped, sorted, string-truncated copy of the reader coverage; input untouched. */
|
|
106
|
+
function capCoverage(coverage, maxDegraded) {
|
|
107
|
+
if (!isPlainObject(coverage)) return null;
|
|
108
|
+
const capped = { ...coverage };
|
|
109
|
+
const degraded = Array.isArray(coverage.degraded)
|
|
110
|
+
? coverage.degraded.filter(isPlainObject).map((entry) => ({
|
|
111
|
+
...entry,
|
|
112
|
+
reason: truncateString(typeof entry.reason === 'string' ? entry.reason : ''),
|
|
113
|
+
file: truncateString(typeof entry.file === 'string' ? entry.file : ''),
|
|
114
|
+
}))
|
|
115
|
+
: [];
|
|
116
|
+
degraded.sort((a, b) => a.reason.localeCompare(b.reason) || a.file.localeCompare(b.file));
|
|
117
|
+
capped.degraded = degraded.slice(0, maxDegraded);
|
|
118
|
+
if (degraded.length > capped.degraded.length) {
|
|
119
|
+
capped.degraded_truncated = degraded.length - capped.degraded.length;
|
|
120
|
+
} else {
|
|
121
|
+
delete capped.degraded_truncated;
|
|
122
|
+
}
|
|
123
|
+
return capped;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function coverageIsDegraded(coverage) {
|
|
127
|
+
if (!isPlainObject(coverage)) return false;
|
|
128
|
+
if (Array.isArray(coverage.degraded) && coverage.degraded.length > 0) return true;
|
|
129
|
+
for (const key of ['corrupt_lines', 'quarantined_segments', 'expired_segments', 'unquarantined_segments']) {
|
|
130
|
+
if (isNumeric(coverage[key]) && coverage[key] > 0) return true;
|
|
131
|
+
}
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function buildUsageReadiness(records, options = {}) {
|
|
136
|
+
const opts = isPlainObject(options) ? options : {};
|
|
137
|
+
const maxDegraded =
|
|
138
|
+
Number.isInteger(opts.maxDegraded) && opts.maxDegraded > 0 ? opts.maxDegraded : MAX_DEGRADED_DEFAULT;
|
|
139
|
+
const scanLimit = Number.isInteger(opts.scanLimit) && opts.scanLimit >= 0 ? opts.scanLimit : null;
|
|
140
|
+
|
|
141
|
+
const counts = { model_completed: 0, model_failed: 0, subagent_usage: 0, cache_hit: 0, cache_miss: 0 };
|
|
142
|
+
const modelBySource = emptyBySource();
|
|
143
|
+
const subagentBySource = emptyBySource();
|
|
144
|
+
const categories = {};
|
|
145
|
+
for (const key of USAGE_CATEGORY_KEYS) {
|
|
146
|
+
categories[key] = { by_source: emptyBySource(), unknown_records: 0 };
|
|
147
|
+
}
|
|
148
|
+
const flags = { providerAggregate: false, numericNonProvider: false, usageRecord: false };
|
|
149
|
+
let providerCategory = false;
|
|
150
|
+
|
|
151
|
+
let recordsScanned = 0;
|
|
152
|
+
let truncated = opts.truncated === true;
|
|
153
|
+
|
|
154
|
+
const visit = (record) => {
|
|
155
|
+
if (!isPlainObject(record)) return;
|
|
156
|
+
const name = typeof record.semantic_name === 'string' ? record.semantic_name : '';
|
|
157
|
+
if (
|
|
158
|
+
name !== 'model.completed' &&
|
|
159
|
+
name !== 'model.failed' &&
|
|
160
|
+
name !== 'subagent.usage' &&
|
|
161
|
+
name !== 'cache.hit' &&
|
|
162
|
+
name !== 'cache.miss'
|
|
163
|
+
) {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
const payload = isPlainObject(record.payload) ? record.payload : {};
|
|
167
|
+
|
|
168
|
+
if (name === 'cache.hit') {
|
|
169
|
+
counts.cache_hit += 1;
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (name === 'cache.miss') {
|
|
173
|
+
counts.cache_miss += 1;
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const isModel = name === 'model.completed' || name === 'model.failed';
|
|
178
|
+
if (isModel) {
|
|
179
|
+
if (name === 'model.completed') counts.model_completed += 1;
|
|
180
|
+
else counts.model_failed += 1;
|
|
181
|
+
} else {
|
|
182
|
+
counts.subagent_usage += 1;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (isModel && isPlainObject(payload.usage_categories)) {
|
|
186
|
+
let hasEntries = false;
|
|
187
|
+
for (const key of USAGE_CATEGORY_KEYS) {
|
|
188
|
+
const entry = payload.usage_categories[key];
|
|
189
|
+
if (!isPlainObject(entry)) continue;
|
|
190
|
+
hasEntries = true;
|
|
191
|
+
addUsageEntry(categories[key].by_source, flags, entry);
|
|
192
|
+
if (entry.source === 'UNKNOWN') categories[key].unknown_records += 1;
|
|
193
|
+
if (entry.source === 'PROVIDER' && isNumeric(entry.value)) providerCategory = true;
|
|
194
|
+
}
|
|
195
|
+
if (hasEntries) flags.usageRecord = true;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (isPlainObject(payload.resource)) {
|
|
199
|
+
flags.usageRecord = true;
|
|
200
|
+
addUsageEntry(isModel ? modelBySource : subagentBySource, flags, payload.resource);
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
if (records !== null && records !== undefined && typeof records[Symbol.iterator] === 'function') {
|
|
205
|
+
for (const record of records) {
|
|
206
|
+
if (scanLimit !== null && recordsScanned === scanLimit) {
|
|
207
|
+
truncated = true;
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
recordsScanned += 1;
|
|
211
|
+
visit(record);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
let verdict;
|
|
216
|
+
let verdict_reason;
|
|
217
|
+
if (providerCategory) {
|
|
218
|
+
verdict = 'categorized';
|
|
219
|
+
verdict_reason = 'provider_categories';
|
|
220
|
+
} else if (flags.providerAggregate) {
|
|
221
|
+
verdict = 'aggregate_only';
|
|
222
|
+
verdict_reason = 'provider_aggregate';
|
|
223
|
+
} else {
|
|
224
|
+
verdict = 'no_data';
|
|
225
|
+
if (flags.numericNonProvider) verdict_reason = 'estimated_only';
|
|
226
|
+
else if (flags.usageRecord) verdict_reason = 'unknown_only';
|
|
227
|
+
else verdict_reason = 'no_usage_records';
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const coverage = capCoverage(opts.coverage, maxDegraded);
|
|
231
|
+
const modelRecords = counts.model_completed + counts.model_failed;
|
|
232
|
+
const providerAggregatePresent =
|
|
233
|
+
modelBySource.PROVIDER.total !== null || subagentBySource.PROVIDER.total !== null;
|
|
234
|
+
const providerCategoryKeys = USAGE_CATEGORY_KEYS.filter(
|
|
235
|
+
(key) => categories[key].by_source.PROVIDER.total !== null,
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
const gaps = [];
|
|
239
|
+
if (modelRecords === 0) gaps.push(GAP_NO_MODEL_RECORDS);
|
|
240
|
+
if (!providerAggregatePresent) gaps.push(GAP_NO_PROVIDER_AGGREGATE);
|
|
241
|
+
if (providerCategoryKeys.length === 0) gaps.push(GAP_NO_PROVIDER_CATEGORIES);
|
|
242
|
+
else if (providerCategoryKeys.length < USAGE_CATEGORY_KEYS.length) gaps.push(GAP_CATEGORIES_PARTIAL);
|
|
243
|
+
if (counts.subagent_usage === 0) gaps.push(GAP_SUBAGENT_USAGE_ABSENT);
|
|
244
|
+
gaps.push(GAP_PROVIDER_IDENTITY);
|
|
245
|
+
gaps.push(GAP_PROMPT_CACHE);
|
|
246
|
+
if (coverageIsDegraded(coverage)) gaps.push(GAP_STORE_COVERAGE);
|
|
247
|
+
if (truncated) gaps.push(GAP_SCAN_TRUNCATED);
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
report_version: USAGE_REPORT_VERSION,
|
|
251
|
+
verdict,
|
|
252
|
+
verdict_reason,
|
|
253
|
+
records_scanned: recordsScanned,
|
|
254
|
+
truncated,
|
|
255
|
+
scan_limit: scanLimit,
|
|
256
|
+
counts,
|
|
257
|
+
aggregate: { model: { by_source: modelBySource }, subagent: { by_source: subagentBySource } },
|
|
258
|
+
categories,
|
|
259
|
+
internal_cache: { hit: counts.cache_hit, miss: counts.cache_miss, scope: INTERNAL_CACHE_SCOPE },
|
|
260
|
+
gaps,
|
|
261
|
+
notes: NOTES.slice(),
|
|
262
|
+
coverage,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function renderBySource(indent, bySource) {
|
|
267
|
+
const lines = [];
|
|
268
|
+
for (const source of SOURCE_KEYS) {
|
|
269
|
+
const bucket = bySource[source];
|
|
270
|
+
const total = isNumeric(bucket.total) ? String(bucket.total) : 'unknown';
|
|
271
|
+
lines.push(`${indent}${source}: ${total} (${bucket.records} records)`);
|
|
272
|
+
}
|
|
273
|
+
return lines;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function renderCoverage(coverage) {
|
|
277
|
+
const lines = [];
|
|
278
|
+
if (!isPlainObject(coverage)) {
|
|
279
|
+
lines.push(' (no coverage information)');
|
|
280
|
+
return lines;
|
|
281
|
+
}
|
|
282
|
+
const counters = [
|
|
283
|
+
'records_read',
|
|
284
|
+
'corrupt_lines',
|
|
285
|
+
'quarantined_segments',
|
|
286
|
+
'expired_segments',
|
|
287
|
+
'unquarantined_segments',
|
|
288
|
+
'partial_tail_bytes',
|
|
289
|
+
];
|
|
290
|
+
for (const key of counters) {
|
|
291
|
+
if (isNumeric(coverage[key])) lines.push(` ${key}: ${coverage[key]}`);
|
|
292
|
+
}
|
|
293
|
+
if (isNumeric(coverage.degraded_truncated)) lines.push(` degraded_truncated: ${coverage.degraded_truncated}`);
|
|
294
|
+
const degraded = Array.isArray(coverage.degraded) ? coverage.degraded : [];
|
|
295
|
+
if (degraded.length === 0) lines.push(' degraded: (none)');
|
|
296
|
+
else {
|
|
297
|
+
lines.push(' degraded:');
|
|
298
|
+
for (const entry of degraded) lines.push(` - ${entry.reason} ${entry.file}`);
|
|
299
|
+
}
|
|
300
|
+
return lines;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export function renderUsageReadiness(report) {
|
|
304
|
+
const lines = [];
|
|
305
|
+
lines.push('Usage readiness (local, read-only)');
|
|
306
|
+
lines.push(`Readiness: ${report.verdict} (${report.verdict_reason})`);
|
|
307
|
+
let scannedLine = `Records scanned: ${report.records_scanned}`;
|
|
308
|
+
if (report.truncated && Number.isInteger(report.scan_limit)) {
|
|
309
|
+
scannedLine += ` (truncated at ${report.scan_limit})`;
|
|
310
|
+
}
|
|
311
|
+
lines.push(scannedLine);
|
|
312
|
+
|
|
313
|
+
lines.push('Aggregate usage (provider-reported only, per source)');
|
|
314
|
+
for (const family of ['model', 'subagent']) {
|
|
315
|
+
lines.push(` ${family}:`);
|
|
316
|
+
lines.push(...renderBySource(' ', report.aggregate[family].by_source));
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
lines.push('Categories');
|
|
320
|
+
for (const key of USAGE_CATEGORY_KEYS) {
|
|
321
|
+
lines.push(` ${key}:`);
|
|
322
|
+
lines.push(...renderBySource(' ', report.categories[key].by_source));
|
|
323
|
+
lines.push(` unknown records: ${report.categories[key].unknown_records}`);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const cache = report.internal_cache;
|
|
327
|
+
const hit = isNumeric(cache.hit) ? String(cache.hit) : 'unknown';
|
|
328
|
+
const miss = isNumeric(cache.miss) ? String(cache.miss) : 'unknown';
|
|
329
|
+
lines.push(`Internal UKit cache lookups (not a provider prompt cache): hit ${hit} miss ${miss}`);
|
|
330
|
+
|
|
331
|
+
lines.push('Gaps:');
|
|
332
|
+
if (report.gaps.length === 0) lines.push(' (none)');
|
|
333
|
+
else for (const gap of report.gaps) lines.push(` - ${gap}`);
|
|
334
|
+
|
|
335
|
+
lines.push('Store coverage:');
|
|
336
|
+
lines.push(...renderCoverage(report.coverage));
|
|
337
|
+
|
|
338
|
+
lines.push('Notes:');
|
|
339
|
+
for (const note of report.notes) lines.push(` - ${note}`);
|
|
340
|
+
|
|
341
|
+
return lines.join('\n');
|
|
342
|
+
}
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* redaction rules change so downstream readers can detect stale records.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
export const REDACTION_VERSION = 'df-redact-
|
|
14
|
+
export const REDACTION_VERSION = 'df-redact-7';
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* Envelope fields permitted on a persisted record (DF-FR01) plus the
|
|
@@ -188,6 +188,15 @@ export const ALLOWED_SUPPORT_PAYLOAD_FIELDS = new Set([
|
|
|
188
188
|
'tokens',
|
|
189
189
|
'parent_run_ref',
|
|
190
190
|
'telemetry_complete',
|
|
191
|
+
// C98 FR-03 (TASK-C98-003): model token usage provenance — the closed
|
|
192
|
+
// usage_categories map and its five category keys. Counts and source tags
|
|
193
|
+
// only, never content.
|
|
194
|
+
'usage_categories',
|
|
195
|
+
'fresh_input_tokens',
|
|
196
|
+
'cache_read_tokens',
|
|
197
|
+
'cache_write_tokens',
|
|
198
|
+
'output_tokens',
|
|
199
|
+
'reasoning_tokens',
|
|
191
200
|
]);
|
|
192
201
|
|
|
193
202
|
// --- size caps (prototype defaults per SPEC §14; freeze after measurement) ---
|
|
@@ -61,6 +61,19 @@ export const IMPORTANCE_LEVELS = Object.freeze(['low', 'normal', 'high', 'critic
|
|
|
61
61
|
// never 0.
|
|
62
62
|
export const RESOURCE_SOURCES = Object.freeze(['PROVIDER', 'LOCAL_TOKENIZER', 'ESTIMATED', 'UNKNOWN']);
|
|
63
63
|
|
|
64
|
+
// Schema v1.3 additive payload extension (C98 FR-02): typed per-category
|
|
65
|
+
// token resources on model.completed|failed. Closed key set; an absent
|
|
66
|
+
// category reads as unknown — never 0.
|
|
67
|
+
export const USAGE_CATEGORY_KEYS = Object.freeze([
|
|
68
|
+
'fresh_input_tokens',
|
|
69
|
+
'cache_read_tokens',
|
|
70
|
+
'cache_write_tokens',
|
|
71
|
+
'output_tokens',
|
|
72
|
+
'reasoning_tokens',
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
export const USAGE_CATEGORIES_FIELD = 'usage_categories';
|
|
76
|
+
|
|
64
77
|
// SPEC §7: confidence is always typed and never an instruction authority.
|
|
65
78
|
export const CONFIDENCE_TYPES = Object.freeze([
|
|
66
79
|
'HEURISTIC',
|
|
@@ -16,6 +16,7 @@ import { PRIVACY_CLASSES } from './constants.js';
|
|
|
16
16
|
const V1 = '1';
|
|
17
17
|
const V1_1 = '1.1';
|
|
18
18
|
const V1_2 = '1.2';
|
|
19
|
+
const V1_3 = '1.3';
|
|
19
20
|
|
|
20
21
|
export const SEMANTIC_REGISTRY = Object.freeze({
|
|
21
22
|
'execution.started': {
|
|
@@ -387,3 +388,22 @@ for (const [name, entry] of Object.entries(SEMANTIC_REGISTRY)) {
|
|
|
387
388
|
throw new Error(`semantic registry entry ${name} has unknown privacy_class ${entry.privacy_class}`);
|
|
388
389
|
}
|
|
389
390
|
}
|
|
391
|
+
|
|
392
|
+
// Schema v1.3 additive payload extensions (TASK-C98-002, SPEC §5 FR-02).
|
|
393
|
+
// The semantic registry is append-only and model.completed|failed cannot be
|
|
394
|
+
// re-defined in place, so the typed per-category usage field is versioned
|
|
395
|
+
// here instead: a record either carries usage_categories (validated per
|
|
396
|
+
// FR-02) or not — absence reads as unknown, never 0.
|
|
397
|
+
const USAGE_CATEGORIES_EXTENSION = Object.freeze({
|
|
398
|
+
introduced_version: V1_3,
|
|
399
|
+
definition:
|
|
400
|
+
'Typed per-category token usage: five optional {value, source} resources '
|
|
401
|
+
+ '(fresh_input_tokens, cache_read_tokens, cache_write_tokens, output_tokens, reasoning_tokens); '
|
|
402
|
+
+ 'value is a non-negative finite integer or null; null only with source UNKNOWN — '
|
|
403
|
+
+ 'an absent category or UNKNOWN stays unknown, never 0.',
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
export const PAYLOAD_EXTENSIONS = Object.freeze({
|
|
407
|
+
'model.completed': Object.freeze({ usage_categories: USAGE_CATEGORIES_EXTENSION }),
|
|
408
|
+
'model.failed': Object.freeze({ usage_categories: USAGE_CATEGORIES_EXTENSION }),
|
|
409
|
+
});
|
|
@@ -25,6 +25,8 @@ import {
|
|
|
25
25
|
IMPORTANCE_LEVELS,
|
|
26
26
|
RESOURCE_SOURCES,
|
|
27
27
|
CONFIDENCE_TYPES,
|
|
28
|
+
USAGE_CATEGORY_KEYS,
|
|
29
|
+
USAGE_CATEGORIES_FIELD,
|
|
28
30
|
} from './constants.js';
|
|
29
31
|
import { SEMANTIC_REGISTRY, REASON_CODES } from './registry.js';
|
|
30
32
|
|
|
@@ -100,7 +102,48 @@ function validateOrigin(record, errors) {
|
|
|
100
102
|
}
|
|
101
103
|
}
|
|
102
104
|
|
|
103
|
-
|
|
105
|
+
// Schema v1.3 additive payload extension (SPEC §5 FR-02): typed per-category
|
|
106
|
+
// token usage on model.completed|failed. Additive — records without the
|
|
107
|
+
// field validate exactly as before; an absent category reads as unknown.
|
|
108
|
+
function validateUsageCategories(payload, semanticName, errors) {
|
|
109
|
+
const categories = payload[USAGE_CATEGORIES_FIELD];
|
|
110
|
+
if (categories === undefined || categories === null) return;
|
|
111
|
+
|
|
112
|
+
if (semanticName !== 'model.completed' && semanticName !== 'model.failed') {
|
|
113
|
+
errors.push(`payload.${USAGE_CATEGORIES_FIELD} is only valid on model.completed|model.failed`);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (!isPlainObject(categories)) {
|
|
117
|
+
errors.push(`payload.${USAGE_CATEGORIES_FIELD} must be an object keyed by usage category`);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
for (const [key, category] of Object.entries(categories)) {
|
|
121
|
+
if (!USAGE_CATEGORY_KEYS.includes(key)) {
|
|
122
|
+
errors.push(`payload.${USAGE_CATEGORIES_FIELD}.${key} is not a known usage category (allowed: ${USAGE_CATEGORY_KEYS.join('|')})`);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (!isPlainObject(category)) {
|
|
126
|
+
errors.push(`payload.${USAGE_CATEGORIES_FIELD}.${key} must be an object {value, source}`);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (!RESOURCE_SOURCES.includes(category.source)) {
|
|
130
|
+
errors.push(`payload.${USAGE_CATEGORIES_FIELD}.${key}.source must be one of ${RESOURCE_SOURCES.join('|')} (got ${JSON.stringify(category.source)})`);
|
|
131
|
+
}
|
|
132
|
+
const { value } = category;
|
|
133
|
+
if (value === null) {
|
|
134
|
+
if (category.source !== 'UNKNOWN') {
|
|
135
|
+
errors.push(`payload.${USAGE_CATEGORIES_FIELD}.${key}.value must be null only when source is UNKNOWN`);
|
|
136
|
+
}
|
|
137
|
+
} else if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
|
|
138
|
+
errors.push(`payload.${USAGE_CATEGORIES_FIELD}.${key}.value must be a finite integer >= 0 or null`);
|
|
139
|
+
} else if (category.source === 'UNKNOWN') {
|
|
140
|
+
errors.push(`payload.${USAGE_CATEGORIES_FIELD}.${key}.value must be null when source is UNKNOWN — unknown is never 0`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function validatePayload(payload, semanticName, errors) {
|
|
146
|
+
validateUsageCategories(payload, semanticName, errors);
|
|
104
147
|
if (payload.resource !== undefined && payload.resource !== null) {
|
|
105
148
|
const resource = payload.resource;
|
|
106
149
|
if (!isPlainObject(resource)) {
|
|
@@ -264,7 +307,7 @@ export function validateSemanticRecord(record, context) {
|
|
|
264
307
|
if (!isPlainObject(record.payload)) {
|
|
265
308
|
errors.push('payload must be an object');
|
|
266
309
|
} else {
|
|
267
|
-
validatePayload(record.payload, errors);
|
|
310
|
+
validatePayload(record.payload, record.semantic_name, errors);
|
|
268
311
|
}
|
|
269
312
|
|
|
270
313
|
if (RECORD_TYPES.includes(record.record_type)) {
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Typed reader over the append-only JSONL segment store.
|
|
5
5
|
*
|
|
6
|
-
* readSegments(root, { fromInclusive?, limit?, ioTimeoutMs?, zlib? })
|
|
6
|
+
* readSegments(root, { fromInclusive?, limit?, ioTimeoutMs?, zlib?, readOnly? })
|
|
7
7
|
* → AsyncIterable<record> with attached:
|
|
8
8
|
* .coverage → { expired_segments, quarantined_segments, corrupt_lines,
|
|
9
9
|
* partial_tail_bytes, cursor_missed, degraded[] }
|
|
@@ -26,6 +26,14 @@
|
|
|
26
26
|
* A partial tail row in the active segment (hard-kill artifact) is skipped
|
|
27
27
|
* and counted, never parsed. Symlinked or foreign-named files are never
|
|
28
28
|
* followed or read.
|
|
29
|
+
*
|
|
30
|
+
* Read-only mode (`readOnly: true`, strict boolean): the reader never mutates
|
|
31
|
+
* the store — corrupt sealed segments (checksum mismatch, missing meta, failed
|
|
32
|
+
* decode) are skipped and reported with `checksum_mismatch_unquarantined` /
|
|
33
|
+
* `decode_failed_unquarantined` degraded entries plus a
|
|
34
|
+
* `coverage.unquarantined_segments` counter (that key exists only in
|
|
35
|
+
* read-only mode) instead of being quarantined. Non-boolean values are
|
|
36
|
+
* treated as false. Default mode is unchanged and still quarantines.
|
|
29
37
|
*/
|
|
30
38
|
|
|
31
39
|
import fs from 'node:fs';
|
|
@@ -49,6 +57,9 @@ export function readSegments(root, opts = {}) {
|
|
|
49
57
|
const timeoutMs = Number.isFinite(opts.ioTimeoutMs) ? opts.ioTimeoutMs : DEFAULT_IO_TIMEOUT_MS;
|
|
50
58
|
const fromInclusive = typeof opts.fromInclusive === 'string' ? opts.fromInclusive : null;
|
|
51
59
|
const limit = Number.isInteger(opts.limit) && opts.limit >= 0 ? opts.limit : null;
|
|
60
|
+
// Strict boolean gate: truthy non-boolean values ('yes', 1) must NOT enable
|
|
61
|
+
// read-only mode — the caller opted into quarantine-free reads explicitly.
|
|
62
|
+
const readOnly = opts.readOnly === true;
|
|
52
63
|
|
|
53
64
|
const coverage = {
|
|
54
65
|
expired_segments: 0,
|
|
@@ -58,6 +69,7 @@ export function readSegments(root, opts = {}) {
|
|
|
58
69
|
cursor_missed: false,
|
|
59
70
|
degraded: [],
|
|
60
71
|
};
|
|
72
|
+
if (readOnly) coverage.unquarantined_segments = 0;
|
|
61
73
|
const degrade = (reason, file) => {
|
|
62
74
|
coverage.degraded.push({ reason, file });
|
|
63
75
|
};
|
|
@@ -123,6 +135,11 @@ export function readSegments(root, opts = {}) {
|
|
|
123
135
|
const checksumOk =
|
|
124
136
|
meta && typeof meta.checksum === 'string' && meta.checksum === sha256Buffer(buf);
|
|
125
137
|
if (!checksumOk) {
|
|
138
|
+
if (readOnly) {
|
|
139
|
+
coverage.unquarantined_segments += 1;
|
|
140
|
+
degrade('checksum_mismatch_unquarantined', seg.name);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
126
143
|
const q = await quarantineSegment(resolved.root, seg.name, 'checksum_mismatch', {
|
|
127
144
|
ioTimeoutMs: timeoutMs,
|
|
128
145
|
});
|
|
@@ -150,6 +167,11 @@ export function readSegments(root, opts = {}) {
|
|
|
150
167
|
if (decoded === null || decoded.length === 0) {
|
|
151
168
|
// Stored bytes verified but not a valid/non-empty zstd stream →
|
|
152
169
|
// corrupt like a bad .jsonl: quarantine, never falsify rows.
|
|
170
|
+
if (readOnly) {
|
|
171
|
+
coverage.unquarantined_segments += 1;
|
|
172
|
+
degrade('decode_failed_unquarantined', seg.name);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
153
175
|
const q = await quarantineSegment(resolved.root, seg.name, 'decode_failed', {
|
|
154
176
|
ioTimeoutMs: timeoutMs,
|
|
155
177
|
});
|
|
@@ -143,9 +143,19 @@ const REASON_CODES = new Set([
|
|
|
143
143
|
|
|
144
144
|
const RESOURCE_SEMANTICS = new Set(['model.completed', 'model.failed']);
|
|
145
145
|
|
|
146
|
+
// Schema v1.3 additive payload extension (C98 FR-02): closed usage-category
|
|
147
|
+
// key set — mirror of USAGE_CATEGORY_KEYS in schema/constants.js.
|
|
148
|
+
const USAGE_CATEGORY_KEYS = Object.freeze([
|
|
149
|
+
'fresh_input_tokens',
|
|
150
|
+
'cache_read_tokens',
|
|
151
|
+
'cache_write_tokens',
|
|
152
|
+
'output_tokens',
|
|
153
|
+
'reasoning_tokens',
|
|
154
|
+
]);
|
|
155
|
+
|
|
146
156
|
// --- frozen privacy contract (mirror of privacy/allowlist.js) ------------
|
|
147
157
|
|
|
148
|
-
const REDACTION_VERSION = 'df-redact-
|
|
158
|
+
const REDACTION_VERSION = 'df-redact-7';
|
|
149
159
|
const ALLOWED_FIELDS = ENVELOPE_FIELDS;
|
|
150
160
|
const DENIED_PAYLOAD_FIELDS = new Set([
|
|
151
161
|
'error_detail', 'feedback', 'comment', 'note_text', 'prompt', 'prompt_text',
|
|
@@ -299,7 +309,47 @@ function sanitizeObserved(record, options = {}) {
|
|
|
299
309
|
|
|
300
310
|
// --- validate (mirror of schema/validate.js, sans cross-record context) ----
|
|
301
311
|
|
|
302
|
-
|
|
312
|
+
// Schema v1.3 additive payload extension (SPEC FR-02): typed per-category
|
|
313
|
+
// token usage on model.completed|failed. Accept/reject outcomes must match
|
|
314
|
+
// validateUsageCategories in src/core/observability/schema/validate.js
|
|
315
|
+
// exactly; message texts may differ.
|
|
316
|
+
function validateUsageCategories(payload, semanticName, errors) {
|
|
317
|
+
const categories = payload.usage_categories;
|
|
318
|
+
if (categories === undefined || categories === null) return;
|
|
319
|
+
if (semanticName !== 'model.completed' && semanticName !== 'model.failed') {
|
|
320
|
+
errors.push('payload.usage_categories is only valid on model.completed|model.failed');
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
if (!isPlainObject(categories)) {
|
|
324
|
+
errors.push('payload.usage_categories must be an object keyed by usage category');
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
for (const [key, category] of Object.entries(categories)) {
|
|
328
|
+
if (!USAGE_CATEGORY_KEYS.includes(key)) {
|
|
329
|
+
errors.push(`payload.usage_categories.${key} is not a known usage category`);
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
if (!isPlainObject(category)) {
|
|
333
|
+
errors.push(`payload.usage_categories.${key} must be an object {value, source}`);
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
if (!RESOURCE_SOURCES.has(category.source)) {
|
|
337
|
+
errors.push(`payload.usage_categories.${key}.source invalid`);
|
|
338
|
+
}
|
|
339
|
+
const { value } = category;
|
|
340
|
+
if (value === null) {
|
|
341
|
+
if (category.source !== 'UNKNOWN') {
|
|
342
|
+
errors.push(`payload.usage_categories.${key}.value must be null only when source is UNKNOWN`);
|
|
343
|
+
}
|
|
344
|
+
} else if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
|
|
345
|
+
errors.push(`payload.usage_categories.${key}.value must be a finite integer >= 0 or null`);
|
|
346
|
+
} else if (category.source === 'UNKNOWN') {
|
|
347
|
+
errors.push(`payload.usage_categories.${key}.value must be null when source is UNKNOWN`);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export function validateSemanticRecord(record) {
|
|
303
353
|
const errors = [];
|
|
304
354
|
if (!isPlainObject(record)) return { ok: false, errors: ['record must be a plain object'] };
|
|
305
355
|
|
|
@@ -385,6 +435,7 @@ function validateSemanticRecord(record) {
|
|
|
385
435
|
errors.push('payload must be an object');
|
|
386
436
|
} else {
|
|
387
437
|
const payload = record.payload;
|
|
438
|
+
validateUsageCategories(payload, record.semantic_name, errors);
|
|
388
439
|
if (payload.resource !== undefined && payload.resource !== null) {
|
|
389
440
|
const resource = payload.resource;
|
|
390
441
|
if (!isPlainObject(resource)) {
|