session-orchestrator 5.9.0 → 5.11.0
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/AGENTS.md +2 -0
- package/CHANGELOG.md +65 -2
- package/README.md +11 -9
- package/SECURITY.md +1 -1
- package/agents/code-implementer.md +7 -1
- package/docs/USER-GUIDE.md +2 -0
- package/docs/agent-authoring.md +2 -0
- package/docs/ci-setup.md +46 -1
- package/docs/events-schema.md +37 -6
- package/docs/fleet-checkin-audit.md +172 -0
- package/docs/owner-config-schema.md +18 -1
- package/docs/owner-privacy-patterns.md +85 -0
- package/docs/role-profile-contract.md +139 -0
- package/docs/scope-collision-guard.md +15 -2
- package/docs/session-config-reference.md +1 -1
- package/evals/instruction-ablation/run.mjs +11 -6
- package/hooks/_lib/hook-import-set.json +27 -2
- package/hooks/_lib/orchestrator-ignore.mjs +112 -0
- package/hooks/enforce-commands.mjs +9 -19
- package/hooks/enforce-scope.mjs +23 -42
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/on-session-end.mjs +8 -1
- package/hooks/on-session-start.mjs +7 -0
- package/hooks/post-bash-write-verify.mjs +54 -24
- package/hooks/pre-task-scope-disjoint.mjs +12 -0
- package/hooks/subagent-telemetry.mjs +8 -3
- package/hooks/wave-scope-commit-guard.mjs +4 -1
- package/package.json +2 -2
- package/rules/opt-in-stack/security-web.md +1 -1
- package/scripts/emit-session.mjs +28 -0
- package/scripts/fleet-checkin-audit.mjs +138 -0
- package/scripts/lib/common.mjs +2 -4
- package/scripts/lib/context-overhead-format.mjs +57 -0
- package/scripts/lib/fleet-checkin-audit.mjs +244 -0
- package/scripts/lib/gates/gate-full.mjs +6 -0
- package/scripts/lib/gates/gate-helpers.mjs +2 -0
- package/scripts/lib/memory-proposals/sink.mjs +79 -17
- package/scripts/lib/memory-proposals/store.mjs +245 -9
- package/scripts/lib/named-vault-resolver.mjs +1 -1
- package/scripts/lib/owner-yaml.mjs +33 -3
- package/scripts/lib/platform.mjs +70 -1
- package/scripts/lib/pre-push-remote-gate.mjs +107 -0
- package/scripts/lib/project-hygiene.mjs +37 -0
- package/scripts/lib/quality-gate.mjs +6 -1
- package/scripts/lib/redact-spans.mjs +8 -17
- package/scripts/lib/role-profile/schema.mjs +219 -0
- package/scripts/lib/scope-gate.mjs +68 -5
- package/scripts/lib/session-lock.mjs +57 -10
- package/scripts/lib/session-record-repair.mjs +63 -30
- package/scripts/lib/session-schema/constants.mjs +8 -0
- package/scripts/lib/session-schema/validator.mjs +33 -0
- package/scripts/lib/subagents-schema.mjs +9 -0
- package/scripts/lib/telemetry/pricing.mjs +11 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +198 -1015
- package/scripts/lib/vault-backfill/template.mjs +1 -1
- package/scripts/lib/vault-mirror/render-sessions.mjs +15 -3
- package/scripts/lib/vault-status/board-writer.mjs +15 -7
- package/scripts/measure-context-overhead.sh +4 -21
- package/scripts/migrate-vault-paths.mjs +57 -60
- package/scripts/release.mjs +463 -48
- package/scripts/run-quality-gate.mjs +47 -0
- package/scripts/validate-wave-scope.mjs +59 -6
- package/scripts/vault-consolidate.mjs +19 -4
- package/skills/claude-md-drift-check/SKILL.md +1 -1
- package/skills/claude-md-drift-check/checker.mjs +22 -9
- package/skills/npm-publish/SKILL.md +3 -3
- package/skills/release/SKILL.md +2 -2
- package/skills/repo-audit/SKILL.md +1 -1
- package/skills/session-end/metrics-collection.md +28 -4
- package/skills/session-end/session-metrics-write.md +1 -1
- package/skills/session-start/references/phase-1-2-session-lock.md +1 -1
- package/skills/wave-executor/circuit-breaker.md +1 -0
- package/skills/wave-executor/references/wave-loop-review.md +26 -2
|
@@ -9,28 +9,19 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Behaviour is unchanged from the original: this module is a move, not a rewrite.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* (proven by the three cases in `tests/husky/pre-commit-owner-leakage.test.mjs`).
|
|
18
|
-
* Unlike CP11's confidential-names helpers, a redaction sink cannot degrade to
|
|
19
|
-
* inert — an absent redaction prints confidential names into a PUBLIC CI log.
|
|
20
|
-
* So: THIS module serves in-tree consumers, the inline copy serves the vendored
|
|
21
|
-
* path, and `tests/lib/redact-spans.test.mjs` pins the two byte-for-byte against
|
|
22
|
-
* each other. Edit one → that drift guard goes red until both agree again.
|
|
12
|
+
* The owner-leakage scanner remains a standalone vendoring target. Since #1530
|
|
13
|
+
* it omits offending line content entirely and redacts private filename segments,
|
|
14
|
+
* so it no longer carries a second copy of this primitive. In-tree consumers
|
|
15
|
+
* that retain surrounding text use this module; its overlap and ordering
|
|
16
|
+
* contracts are covered by tests/lib/redact-spans.test.mjs.
|
|
23
17
|
*/
|
|
24
18
|
|
|
25
19
|
/**
|
|
26
20
|
* Redact every confidential-name span from `line`, ORDER-INDEPENDENTLY (Fix 1 + Fix 2).
|
|
27
21
|
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* PUBLIC GitHub-Actions mirror: a confidential customer/repo name that co-occurs
|
|
32
|
-
* with a CP1–CP10 hit on the same line (e.g. a name beside an RFC1918 IP that
|
|
33
|
-
* fails CP8) would otherwise be echoed verbatim to the public CI log (Fix 1).
|
|
22
|
+
* Consumers must pass every relevant confidential pattern before emitting text.
|
|
23
|
+
* This helper matches the supplied text literally; callers handling reversible
|
|
24
|
+
* encodings must normalize first or omit the sensitive text entirely.
|
|
34
25
|
*
|
|
35
26
|
* ORDER-INDEPENDENCE (Fix 2): a naïve chain of `.replace()` calls is order-dependent
|
|
36
27
|
* — when one configured name is a PREFIX of another (`['acme','acme-corp-secret']`),
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offline role-profile evidence contract. Refs #1469.
|
|
3
|
+
* Validation checks internal consistency, never source authenticity or runtime capability.
|
|
4
|
+
*/
|
|
5
|
+
export const ROLE_PROFILE_CONTRACT_VERSION = 1;
|
|
6
|
+
|
|
7
|
+
const BINDINGS = new Map([['openai', 'codex-cli'], ['anthropic', 'claude-code']]);
|
|
8
|
+
const DIALECTS = new Map([['openai-reasoning', 'openai'], ['anthropic-effort', 'anthropic']]);
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {unknown} value JSON data, without accessors or class instances.
|
|
12
|
+
* @returns {{success: true, data: unknown, diagnostics: object[]} |
|
|
13
|
+
* {success: false, error: {issues: {code: string, path: (string|number)[], message: string}[]}}}
|
|
14
|
+
*/
|
|
15
|
+
function safeParse(value) {
|
|
16
|
+
const issues = [];
|
|
17
|
+
const diagnostics = [];
|
|
18
|
+
const fail = (code, path, message) => issues.push({ code, path, message });
|
|
19
|
+
const object = (v, keys, path) => {
|
|
20
|
+
if (!v || typeof v !== 'object' || Array.isArray(v) ||
|
|
21
|
+
![Object.prototype, null].includes(Object.getPrototypeOf(v))) {
|
|
22
|
+
fail('INVALID_TYPE', path, 'Expected a plain object');
|
|
23
|
+
return {};
|
|
24
|
+
}
|
|
25
|
+
const descriptors = Object.getOwnPropertyDescriptors(v);
|
|
26
|
+
if (Object.values(descriptors).some(d => !Object.hasOwn(d, 'value'))) {
|
|
27
|
+
fail('INVALID_TYPE', path, 'Expected data properties');
|
|
28
|
+
return {};
|
|
29
|
+
}
|
|
30
|
+
for (const key of Reflect.ownKeys(v)) {
|
|
31
|
+
if (!keys.includes(key)) fail('UNKNOWN_FIELD', [...path, String(key)], 'Unknown field');
|
|
32
|
+
}
|
|
33
|
+
for (const key of keys) {
|
|
34
|
+
if (!Object.hasOwn(v, key)) fail('MISSING_FIELD', [...path, key], 'Required field is missing');
|
|
35
|
+
}
|
|
36
|
+
return v;
|
|
37
|
+
};
|
|
38
|
+
const string = (v, path) => {
|
|
39
|
+
if (typeof v !== 'string' || !v.trim()) fail('INVALID_TYPE', path, 'Expected a nonempty string');
|
|
40
|
+
};
|
|
41
|
+
const isNull = (v, path) => {
|
|
42
|
+
if (v !== null) fail('INVALID_RELATION', path, 'Expected null in this evidence state');
|
|
43
|
+
};
|
|
44
|
+
const oneOf = (v, values, path) => {
|
|
45
|
+
if (!values.includes(v)) fail('INVALID_VALUE', path, 'Unsupported discriminator');
|
|
46
|
+
};
|
|
47
|
+
const relation = (condition, path, message) => {
|
|
48
|
+
if (!condition) fail('INVALID_RELATION', path, message);
|
|
49
|
+
};
|
|
50
|
+
const evidence = (v, field, path, statuses = ['known', 'unknown']) => {
|
|
51
|
+
const e = object(v, ['status', field, 'sourceRef', 'reason'], path);
|
|
52
|
+
oneOf(e.status, statuses, [...path, 'status']);
|
|
53
|
+
if (e.status === 'unknown') {
|
|
54
|
+
isNull(e[field], [...path, field]);
|
|
55
|
+
isNull(e.sourceRef, [...path, 'sourceRef']);
|
|
56
|
+
string(e.reason, [...path, 'reason']);
|
|
57
|
+
} else if (statuses.includes(e.status)) {
|
|
58
|
+
string(e[field], [...path, field]);
|
|
59
|
+
string(e.sourceRef, [...path, 'sourceRef']);
|
|
60
|
+
isNull(e.reason, [...path, 'reason']);
|
|
61
|
+
}
|
|
62
|
+
return e;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const root = object(value, ['contractVersion', 'role', 'taskClass', 'provider', 'transport',
|
|
66
|
+
'binding', 'model', 'effort', 'harness', 'profile', 'registryVersion', 'selection',
|
|
67
|
+
'authorization', 'evidence'], []);
|
|
68
|
+
if (root.contractVersion !== ROLE_PROFILE_CONTRACT_VERSION) {
|
|
69
|
+
fail('UNSUPPORTED_VERSION', ['contractVersion'], 'Expected contract version 1');
|
|
70
|
+
return { success: false, error: { issues } };
|
|
71
|
+
}
|
|
72
|
+
for (const key of ['role', 'taskClass', 'provider', 'transport']) string(root[key], [key]);
|
|
73
|
+
const binding = object(root.binding, ['status', 'sourceRef', 'reason'], ['binding']);
|
|
74
|
+
oneOf(binding.status, ['known', 'unknown'], ['binding', 'status']);
|
|
75
|
+
if (binding.status === 'known') {
|
|
76
|
+
string(binding.sourceRef, ['binding', 'sourceRef']);
|
|
77
|
+
isNull(binding.reason, ['binding', 'reason']);
|
|
78
|
+
relation(BINDINGS.has(root.provider) && BINDINGS.get(root.provider) === root.transport,
|
|
79
|
+
['binding'], 'Known provider/transport binding must match the v1 contract');
|
|
80
|
+
} else if (binding.status === 'unknown') {
|
|
81
|
+
isNull(binding.sourceRef, ['binding', 'sourceRef']);
|
|
82
|
+
string(binding.reason, ['binding', 'reason']);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const model = object(root.model, ['requested', 'observed', 'resolution'], ['model']);
|
|
86
|
+
const requested = object(model.requested, ['kind', 'value'], ['model', 'requested']);
|
|
87
|
+
oneOf(requested.kind, ['id', 'alias', 'inherit'], ['model', 'requested', 'kind']);
|
|
88
|
+
if (requested.kind === 'inherit') isNull(requested.value, ['model', 'requested', 'value']);
|
|
89
|
+
else string(requested.value, ['model', 'requested', 'value']);
|
|
90
|
+
const observed = evidence(model.observed, 'id', ['model', 'observed']);
|
|
91
|
+
const resolution = object(model.resolution,
|
|
92
|
+
['status', 'baselineId', 'parent', 'sourceRef', 'reason'], ['model', 'resolution']);
|
|
93
|
+
oneOf(resolution.status, ['exact', 'alias-resolved', 'alias-drift', 'parent-inherit', 'unknown'],
|
|
94
|
+
['model', 'resolution', 'status']);
|
|
95
|
+
if (observed.status === 'unknown') {
|
|
96
|
+
relation(resolution.status === 'unknown', ['model', 'resolution', 'status'],
|
|
97
|
+
'Unknown observation must retain unknown resolution');
|
|
98
|
+
}
|
|
99
|
+
if (resolution.status === 'unknown') {
|
|
100
|
+
isNull(resolution.baselineId, ['model', 'resolution', 'baselineId']);
|
|
101
|
+
isNull(resolution.parent, ['model', 'resolution', 'parent']);
|
|
102
|
+
isNull(resolution.sourceRef, ['model', 'resolution', 'sourceRef']);
|
|
103
|
+
string(resolution.reason, ['model', 'resolution', 'reason']);
|
|
104
|
+
} else if (['exact', 'alias-resolved', 'alias-drift', 'parent-inherit'].includes(resolution.status)) {
|
|
105
|
+
relation(observed.status === 'known', ['model', 'observed'], 'Resolution requires an observation');
|
|
106
|
+
string(resolution.sourceRef, ['model', 'resolution', 'sourceRef']);
|
|
107
|
+
isNull(resolution.reason, ['model', 'resolution', 'reason']);
|
|
108
|
+
if (resolution.status !== 'alias-drift') isNull(resolution.baselineId, ['model', 'resolution', 'baselineId']);
|
|
109
|
+
if (resolution.status !== 'parent-inherit') isNull(resolution.parent, ['model', 'resolution', 'parent']);
|
|
110
|
+
if (resolution.status === 'exact') {
|
|
111
|
+
relation(requested.kind === 'id' && requested.value === observed.id,
|
|
112
|
+
['model', 'resolution'], 'Exact resolution requires the requested ID');
|
|
113
|
+
} else if (['alias-resolved', 'alias-drift'].includes(resolution.status)) {
|
|
114
|
+
relation(requested.kind === 'alias', ['model', 'requested', 'kind'], 'Alias resolution requires an alias');
|
|
115
|
+
if (resolution.status === 'alias-drift') {
|
|
116
|
+
string(resolution.baselineId, ['model', 'resolution', 'baselineId']);
|
|
117
|
+
relation(resolution.baselineId !== observed.id, ['model', 'resolution', 'baselineId'],
|
|
118
|
+
'Alias drift requires a different documented baseline ID');
|
|
119
|
+
}
|
|
120
|
+
} else {
|
|
121
|
+
const parent = object(resolution.parent, ['id', 'sourceRef'], ['model', 'resolution', 'parent']);
|
|
122
|
+
string(parent.id, ['model', 'resolution', 'parent', 'id']);
|
|
123
|
+
string(parent.sourceRef, ['model', 'resolution', 'parent', 'sourceRef']);
|
|
124
|
+
relation(requested.kind === 'inherit' && parent.id === observed.id,
|
|
125
|
+
['model', 'resolution'], 'Parent inheritance requires matching parent evidence');
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const effort = object(root.effort, ['requested', 'effective', 'support'], ['effort']);
|
|
130
|
+
const requestedEffort = object(effort.requested, ['provider', 'dialect', 'value'], ['effort', 'requested']);
|
|
131
|
+
const effective = object(effort.effective,
|
|
132
|
+
['provider', 'dialect', 'status', 'value', 'sourceRef', 'reason'], ['effort', 'effective']);
|
|
133
|
+
const support = object(effort.support, ['status', 'sourceRef', 'reason'], ['effort', 'support']);
|
|
134
|
+
oneOf(support.status, ['supported', 'unsupported', 'unknown'], ['effort', 'support', 'status']);
|
|
135
|
+
if (support.status === 'unknown') {
|
|
136
|
+
isNull(support.sourceRef, ['effort', 'support', 'sourceRef']);
|
|
137
|
+
string(support.reason, ['effort', 'support', 'reason']);
|
|
138
|
+
} else if (['supported', 'unsupported'].includes(support.status)) {
|
|
139
|
+
string(support.sourceRef, ['effort', 'support', 'sourceRef']);
|
|
140
|
+
isNull(support.reason, ['effort', 'support', 'reason']);
|
|
141
|
+
}
|
|
142
|
+
oneOf(effective.status, ['known', 'unknown'], ['effort', 'effective', 'status']);
|
|
143
|
+
for (const [key, entry] of [['requested', requestedEffort], ['effective', effective]]) {
|
|
144
|
+
string(entry.provider, ['effort', key, 'provider']);
|
|
145
|
+
string(entry.dialect, ['effort', key, 'dialect']);
|
|
146
|
+
relation(entry.provider === root.provider, ['effort', key, 'provider'], 'Effort provider must match the record');
|
|
147
|
+
if (DIALECTS.has(entry.dialect)) {
|
|
148
|
+
relation(DIALECTS.get(entry.dialect) === entry.provider, ['effort', key, 'dialect'],
|
|
149
|
+
'Effort dialect belongs to a different provider');
|
|
150
|
+
} else {
|
|
151
|
+
relation(support.status === 'unknown' && effective.status === 'unknown', ['effort', key, 'dialect'],
|
|
152
|
+
'Unrecognized effort dialect requires unknown support and effective evidence');
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (requestedEffort.value !== null) string(requestedEffort.value, ['effort', 'requested', 'value']);
|
|
156
|
+
if (effective.status === 'unknown') {
|
|
157
|
+
isNull(effective.value, ['effort', 'effective', 'value']);
|
|
158
|
+
isNull(effective.sourceRef, ['effort', 'effective', 'sourceRef']);
|
|
159
|
+
string(effective.reason, ['effort', 'effective', 'reason']);
|
|
160
|
+
} else if (effective.status === 'known') {
|
|
161
|
+
string(effective.value, ['effort', 'effective', 'value']);
|
|
162
|
+
string(effective.sourceRef, ['effort', 'effective', 'sourceRef']);
|
|
163
|
+
isNull(effective.reason, ['effort', 'effective', 'reason']);
|
|
164
|
+
}
|
|
165
|
+
if (support.status === 'unsupported') {
|
|
166
|
+
diagnostics.push({ code: 'UNSUPPORTED_EFFORT', path: ['effort', 'requested', 'value'],
|
|
167
|
+
message: 'Requested effort is documented as unsupported' });
|
|
168
|
+
relation(!(effective.status === 'known' && effective.value === requestedEffort.value),
|
|
169
|
+
['effort', 'effective'], 'Unsupported requested effort cannot also be claimed effective');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const harness = object(root.harness, ['name', 'version'], ['harness']);
|
|
173
|
+
string(harness.name, ['harness', 'name']);
|
|
174
|
+
evidence(harness.version, 'value', ['harness', 'version']);
|
|
175
|
+
const profile = object(root.profile, ['id', 'version'], ['profile']);
|
|
176
|
+
string(profile.id, ['profile', 'id']);
|
|
177
|
+
evidence(profile.version, 'value', ['profile', 'version']);
|
|
178
|
+
evidence(root.registryVersion, 'value', ['registryVersion']);
|
|
179
|
+
const selection = object(root.selection, ['mode', 'escalationReason'], ['selection']);
|
|
180
|
+
oneOf(selection.mode, ['profile', 'owner-override', 'escalation'], ['selection', 'mode']);
|
|
181
|
+
if (selection.mode === 'escalation') string(selection.escalationReason, ['selection', 'escalationReason']);
|
|
182
|
+
else isNull(selection.escalationReason, ['selection', 'escalationReason']);
|
|
183
|
+
const auth = object(root.authorization,
|
|
184
|
+
['status', 'authority', 'sourceRef', 'scope', 'reason'], ['authorization']);
|
|
185
|
+
oneOf(auth.status, ['authorized', 'unknown', 'not-required'], ['authorization', 'status']);
|
|
186
|
+
if (['owner-override', 'escalation'].includes(selection.mode)) {
|
|
187
|
+
relation(auth.status === 'authorized', ['authorization', 'status'], 'Selection requires owner authorization evidence');
|
|
188
|
+
}
|
|
189
|
+
if (auth.status === 'authorized') {
|
|
190
|
+
relation(auth.authority === 'owner', ['authorization', 'authority'], 'Authorization authority must be owner');
|
|
191
|
+
string(auth.sourceRef, ['authorization', 'sourceRef']);
|
|
192
|
+
isNull(auth.reason, ['authorization', 'reason']);
|
|
193
|
+
const scope = object(auth.scope, ['role', 'provider', 'transport', 'model', 'effort'], ['authorization', 'scope']);
|
|
194
|
+
for (const key of ['role', 'provider', 'transport']) {
|
|
195
|
+
string(scope[key], ['authorization', 'scope', key]);
|
|
196
|
+
relation(scope[key] === root[key], ['authorization', 'scope', key], 'Authorization scope must cover this record');
|
|
197
|
+
}
|
|
198
|
+
const scopedModel = object(scope.model, ['kind', 'value'], ['authorization', 'scope', 'model']);
|
|
199
|
+
for (const key of ['kind', 'value']) relation(scopedModel[key] === requested[key],
|
|
200
|
+
['authorization', 'scope', 'model', key], 'Authorization must cover the model request');
|
|
201
|
+
const scopedEffort = object(scope.effort, ['provider', 'dialect', 'value'], ['authorization', 'scope', 'effort']);
|
|
202
|
+
for (const key of ['provider', 'dialect', 'value']) relation(scopedEffort[key] === requestedEffort[key],
|
|
203
|
+
['authorization', 'scope', 'effort', key], 'Authorization must cover the effort request');
|
|
204
|
+
} else if (['unknown', 'not-required'].includes(auth.status)) {
|
|
205
|
+
for (const key of ['authority', 'sourceRef', 'scope']) isNull(auth[key], ['authorization', key]);
|
|
206
|
+
string(auth.reason, ['authorization', 'reason']);
|
|
207
|
+
}
|
|
208
|
+
const measured = evidence(root.evidence, 'timestamp', ['evidence']);
|
|
209
|
+
if (measured.status === 'known') {
|
|
210
|
+
const timestamp = measured.timestamp;
|
|
211
|
+
const valid = typeof timestamp === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(timestamp) &&
|
|
212
|
+
Number.isFinite(Date.parse(timestamp)) && new Date(timestamp).toISOString() === timestamp;
|
|
213
|
+
relation(valid, ['evidence', 'timestamp'], 'Expected an actual UTC timestamp with millisecond precision');
|
|
214
|
+
}
|
|
215
|
+
return issues.length ? { success: false, error: { issues } } : { success: true, data: value, diagnostics };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Versioned schema facade; syntax success grants no runtime approval. */
|
|
219
|
+
export const roleProfileEvidenceSchema = Object.freeze({ safeParse });
|
|
@@ -5,14 +5,15 @@
|
|
|
5
5
|
* hot-paths — all sync. Re-exported by hardening.mjs as a barrel so existing
|
|
6
6
|
* importers keep working unchanged.
|
|
7
7
|
*
|
|
8
|
-
* Layering: hook-safe
|
|
9
|
-
*
|
|
10
|
-
* this lib;
|
|
11
|
-
* invariant for all exports below — see #554 A2.
|
|
8
|
+
* Layering: hook-safe synchronous helpers; no I/O at import time. Guard
|
|
9
|
+
* context lookups read bounded filesystem state when called. Hooks (under
|
|
10
|
+
* `hooks/`) import this ESM-only lib; it MUST NOT reverse-import from hooks.
|
|
11
|
+
* Cross-cutting invariant for all exports below — see #554 A2.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
14
|
+
import { existsSync, readFileSync, realpathSync, lstatSync, openSync, fstatSync, readSync, closeSync, constants } from 'node:fs';
|
|
15
15
|
import path from 'node:path';
|
|
16
|
+
import { resolveSessionRoot, isolatedAgentCheckout, registeredWorktreeRoots } from './platform.mjs';
|
|
16
17
|
|
|
17
18
|
import { tokenizeCommand, splitChainSegments, resolveSegmentVerb } from './command-blocker.mjs';
|
|
18
19
|
|
|
@@ -106,6 +107,68 @@ export function findOwnScopeFile(projectRoot, ownIds, classify) {
|
|
|
106
107
|
return { path: null, foreignPath };
|
|
107
108
|
}
|
|
108
109
|
|
|
110
|
+
/**
|
|
111
|
+
* Guard context: control state belongs to the coordinator, relative grants to
|
|
112
|
+
* the actual isolated checkout. Remote adoption requires unique OWN identity;
|
|
113
|
+
* local unknown/corrupt/foreign/missing dispositions remain unchanged.
|
|
114
|
+
* Root-only discovery deliberately excludes arbitrary worktree subdirectories;
|
|
115
|
+
* revisit when the harness supplies a coordinator-root witness (#1504).
|
|
116
|
+
* @param {{cwd: string, ownIds: Set<string>, classify: Function}} options
|
|
117
|
+
* @returns {{manifestRoot: string, writeRoot: string, path: string|null,
|
|
118
|
+
* foreignPath: string|null, isolated: boolean, error: string|null}}
|
|
119
|
+
*/
|
|
120
|
+
export function resolveScopeContext({ cwd, ownIds, classify }) {
|
|
121
|
+
let manifestRoot = resolveSessionRoot(cwd);
|
|
122
|
+
try { manifestRoot = realpathSync.native(manifestRoot); } catch { /* preserve non-existing-root fallback */ }
|
|
123
|
+
const local = findOwnScopeFile(manifestRoot, ownIds, classify);
|
|
124
|
+
const agent = isolatedAgentCheckout(cwd);
|
|
125
|
+
const context = { manifestRoot, writeRoot: agent?.writeRoot ?? manifestRoot,
|
|
126
|
+
...local, isolated: agent !== null, error: null };
|
|
127
|
+
if (!agent || local.path || ownIds.size === 0) return context;
|
|
128
|
+
const candidates = registeredWorktreeRoots(agent.commonDir);
|
|
129
|
+
if (candidates.error) return { ...context, error: candidates.error };
|
|
130
|
+
const matches = new Map();
|
|
131
|
+
for (const root of candidates.roots) {
|
|
132
|
+
if (root === agent.writeRoot || root === manifestRoot || isolatedAgentCheckout(root)) continue;
|
|
133
|
+
// Inspect precedence candidates separately. An unsafe candidate has unknown
|
|
134
|
+
// ownership: it must never turn an isolated session into a no-scope ALLOW.
|
|
135
|
+
for (const dir of SCOPE_FILE_DIRS) {
|
|
136
|
+
const file = path.join(root, dir, 'wave-scope.json');
|
|
137
|
+
let fd;
|
|
138
|
+
let raw;
|
|
139
|
+
try {
|
|
140
|
+
const before = lstatSync(file);
|
|
141
|
+
if (!before.isFile() || before.size > 1024 * 1024) return { ...context, error: 'unsafe-coordinator-scope' };
|
|
142
|
+
fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
143
|
+
const opened = fstatSync(fd);
|
|
144
|
+
if (!opened.isFile() || opened.ino !== before.ino || opened.dev !== before.dev || opened.size > 1024 * 1024) return { ...context, error: 'unsafe-coordinator-scope' };
|
|
145
|
+
const buffer = Buffer.alloc(1024 * 1024 + 1);
|
|
146
|
+
let length = 0;
|
|
147
|
+
while (length < buffer.length) {
|
|
148
|
+
const n = readSync(fd, buffer, length, buffer.length - length, null);
|
|
149
|
+
if (n === 0) break;
|
|
150
|
+
length += n;
|
|
151
|
+
}
|
|
152
|
+
if (length > 1024 * 1024) return { ...context, error: 'unsafe-coordinator-scope' };
|
|
153
|
+
raw = buffer.toString('utf8', 0, length);
|
|
154
|
+
} catch (err) {
|
|
155
|
+
if (err?.code === 'ENOENT') continue;
|
|
156
|
+
return { ...context, error: 'unsafe-coordinator-scope' };
|
|
157
|
+
} finally { if (fd !== undefined) closeSync(fd); }
|
|
158
|
+
try {
|
|
159
|
+
const scope = JSON.parse(raw);
|
|
160
|
+
if (classify(scope, ownIds).verdict === 'own') {
|
|
161
|
+
matches.set(realpathSync(file), { root, path: file, foreignPath: null });
|
|
162
|
+
break; // highest-precedence positively owned manifest in this root
|
|
163
|
+
}
|
|
164
|
+
} catch { /* unbound/corrupt remote state is never authority */ }
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (matches.size > 1) return { ...context, error: 'ambiguous-coordinator-scope' };
|
|
168
|
+
const match = matches.values().next().value;
|
|
169
|
+
return match ? { ...context, manifestRoot: match.root, path: match.path, foreignPath: null } : context;
|
|
170
|
+
}
|
|
171
|
+
|
|
109
172
|
/**
|
|
110
173
|
* Read the enforcement level from a scope file.
|
|
111
174
|
* Defaults to "strict" (fail-closed) on parse error or missing field.
|
|
@@ -527,8 +527,10 @@ export function readLockDetailed(opts = {}) {
|
|
|
527
527
|
* result — this keeps acquire() synchronous.
|
|
528
528
|
*
|
|
529
529
|
* Returns one of:
|
|
530
|
-
* { ok: true, lock, exclusivityClass
|
|
531
|
-
* — lock created
|
|
530
|
+
* { ok: true, lock, exclusivityClass?, ownerProof }
|
|
531
|
+
* — lock created. `ownerProof` reports the owner-proof rewrite that
|
|
532
|
+
* follows every successful lock write (#1541, see withOwnerProof()):
|
|
533
|
+
* `{ ok: true }` or `{ ok: false, reason }` — never thrown.
|
|
532
534
|
* { ok: false, reason: 'active', existingLock, ageHours, exclusivityClass? }
|
|
533
535
|
* — local lock held: its last_heartbeat is within ttl_hours. `ageHours`
|
|
534
536
|
* is the age from `started_at` (null when unparseable). `existingLock`
|
|
@@ -777,7 +779,7 @@ export function acquire({ sessionId, mode, ttlHours = DEFAULT_TTL_HOURS, repoRoo
|
|
|
777
779
|
const createResult = createSessionLockExclusive(lockFile, lock);
|
|
778
780
|
|
|
779
781
|
if (createResult.ok) {
|
|
780
|
-
return { ok: true, lock, exclusivityClass: callerClass };
|
|
782
|
+
return withOwnerProof({ ok: true, lock, exclusivityClass: callerClass }, repoRoot);
|
|
781
783
|
}
|
|
782
784
|
if (createResult.reason === 'fs-error') {
|
|
783
785
|
return { ok: false, reason: 'fs-error', error: createResult.error, exclusivityClass: callerClass };
|
|
@@ -804,7 +806,8 @@ export function acquire({ sessionId, mode, ttlHours = DEFAULT_TTL_HOURS, repoRoo
|
|
|
804
806
|
* Call only after the user has explicitly authorised stale-lock takeover.
|
|
805
807
|
*
|
|
806
808
|
* Returns:
|
|
807
|
-
* { ok: true, lock, replacedLock
|
|
809
|
+
* { ok: true, lock, replacedLock?, ownerProof } — lock written (replacedLock present if one was overwritten;
|
|
810
|
+
* ownerProof as on acquire(), #1541)
|
|
808
811
|
* { ok: false, reason: 'missing-session-id' } — no usable `sessionId` given; NOTHING was written
|
|
809
812
|
* { ok: false, reason: 'fs-error', ... } — filesystem failure
|
|
810
813
|
*
|
|
@@ -833,6 +836,48 @@ export function acquire({ sessionId, mode, ttlHours = DEFAULT_TTL_HOURS, repoRoo
|
|
|
833
836
|
*
|
|
834
837
|
* @param {{ sessionId: string, mode: string, ttlHours?: number, repoRoot?: string, semanticSessionId?: string, onlyIfNotLive?: boolean }} args
|
|
835
838
|
*/
|
|
839
|
+
/**
|
|
840
|
+
* Rewrite `.orchestrator/runtime/lock-owner-proof.json` for a lock this module
|
|
841
|
+
* has JUST written, and report the outcome on the result (#1541).
|
|
842
|
+
*
|
|
843
|
+
* Invariant: proof = current lock. Before #1541 only the SessionStart bootstrap
|
|
844
|
+
* wrote the proof, so a lock taken on the prose path (Phase 1.2 `acquire()`
|
|
845
|
+
* after a `/close` in the same conversation, or a force-take / reclaim) left
|
|
846
|
+
* the PREVIOUS lock's proof on disk — and `release()` answered
|
|
847
|
+
* `proof-mismatch` for the session's own lock at its next `/close`.
|
|
848
|
+
*
|
|
849
|
+
* Called only on the `ok: true` paths, directly after the lock write, with the
|
|
850
|
+
* same `repoRoot` the lock path was resolved from (both fall back to
|
|
851
|
+
* `process.cwd()`), so the proof lands beside the lock it describes. The
|
|
852
|
+
* bootstrap's own later writeOwnerProof() on the enriched lock yields the same
|
|
853
|
+
* triple (enrichment never touches pid/host/started_at) — idempotent.
|
|
854
|
+
*
|
|
855
|
+
* Never throws and never turns `ok: true` into a failure: the lock IS held, and
|
|
856
|
+
* a missing proof only degrades `/close` to the session_id-only release.
|
|
857
|
+
*
|
|
858
|
+
* Ceiling (BV-004): two plain forceAcquire() calls racing on one path are
|
|
859
|
+
* last-writer-wins for the lock AND the proof, and the two writes can
|
|
860
|
+
* interleave (A lock, B lock, B proof, A proof). The proof then names A while B
|
|
861
|
+
* holds the lock — the same state as before #1541, and safe: release() still
|
|
862
|
+
* gates on the raw session_id first, so the mismatched proof can only refuse a
|
|
863
|
+
* release, never grant one. Revisit if `release-proof-mismatch` shows up after
|
|
864
|
+
* a force-take.
|
|
865
|
+
*
|
|
866
|
+
* Known limit: the proof is one file per repo and identifies the process that
|
|
867
|
+
* last wrote the lock, never the conversation. Twins sharing one raw
|
|
868
|
+
* session_id (two terminals resuming the same conversation) are therefore
|
|
869
|
+
* indistinguishable to it — either can release after the other force-took.
|
|
870
|
+
*
|
|
871
|
+
* @param {{ ok: true, lock: object }} result
|
|
872
|
+
* @param {string|undefined} repoRoot
|
|
873
|
+
* @returns {object} the same result object, with `ownerProof` set
|
|
874
|
+
*/
|
|
875
|
+
function withOwnerProof(result, repoRoot) {
|
|
876
|
+
const w = writeOwnerProof({ repoRoot, lock: result.lock });
|
|
877
|
+
result.ownerProof = w.ok ? { ok: true } : { ok: false, reason: w.reason };
|
|
878
|
+
return result;
|
|
879
|
+
}
|
|
880
|
+
|
|
836
881
|
/**
|
|
837
882
|
* A `sessionId` is usable only when it is a non-blank string. ONE predicate,
|
|
838
883
|
* shared by acquire() and forceAcquire(), so the two entry points cannot drift
|
|
@@ -863,7 +908,7 @@ export function forceAcquire({ sessionId, mode, ttlHours = DEFAULT_TTL_HOURS, re
|
|
|
863
908
|
|
|
864
909
|
const result = { ok: true, lock };
|
|
865
910
|
if (replacedLock !== null) result.replacedLock = replacedLock;
|
|
866
|
-
return result;
|
|
911
|
+
return withOwnerProof(result, repoRoot);
|
|
867
912
|
} catch (err) {
|
|
868
913
|
return { ok: false, reason: 'fs-error', error: err.message };
|
|
869
914
|
}
|
|
@@ -948,7 +993,7 @@ function reclaimIfNotLive({ sessionId, mode, ttlHours, repoRoot, semanticSession
|
|
|
948
993
|
if (created.ok) {
|
|
949
994
|
const result = { ok: true, lock };
|
|
950
995
|
if (moved !== null) result.replacedLock = moved;
|
|
951
|
-
return result;
|
|
996
|
+
return withOwnerProof(result, repoRoot);
|
|
952
997
|
}
|
|
953
998
|
if (created.reason === 'exists') return reacquire();
|
|
954
999
|
return created;
|
|
@@ -1108,10 +1153,12 @@ export function isLockOwnedByProof(lock, proof) {
|
|
|
1108
1153
|
* persistence at lock genesis; Part 2, the on-session-end consumption side,
|
|
1109
1154
|
* is a separate change).
|
|
1110
1155
|
*
|
|
1111
|
-
*
|
|
1112
|
-
*
|
|
1113
|
-
*
|
|
1114
|
-
* `
|
|
1156
|
+
* Callers: `acquire()` / `forceAcquire()` on every `ok: true` lock write
|
|
1157
|
+
* (#1541, via withOwnerProof()), and `bootstrapLock()`
|
|
1158
|
+
* (hooks/_lib/lock-bootstrap.mjs) again immediately after its enriched-lock
|
|
1159
|
+
* write. In every case the `lock` argument is byte-identical to the on-disk
|
|
1160
|
+
* lock, so the proof it yields will verify via `isLockOwnedByProof()` against
|
|
1161
|
+
* any later re-read of that same lock.
|
|
1115
1162
|
*
|
|
1116
1163
|
* Envelope shape (schema_version 1):
|
|
1117
1164
|
* {
|
|
@@ -108,13 +108,15 @@ const INCOMPLETE_FIELD_ORDER = Object.freeze([
|
|
|
108
108
|
'total_agents',
|
|
109
109
|
'total_files_changed',
|
|
110
110
|
]);
|
|
111
|
-
// `timestamp_precision` (#1442)
|
|
112
|
-
//
|
|
113
|
-
// default. Listing it would make
|
|
114
|
-
// `sessions-staleness-banner.mjs` returns null
|
|
115
|
-
// `_backfill_incomplete_fields`,
|
|
116
|
-
// `includes('started_at')`. The
|
|
117
|
-
// `_<field>_raw` sidecar instead.
|
|
111
|
+
// `timestamp_precision` (#1442) and `timestamp_offset` (#1525) deliberately add
|
|
112
|
+
// NOTHING to the list above: a truncated or UTC-converted timestamp is still a
|
|
113
|
+
// measurement (exact to the millisecond), not a default. Listing it would make
|
|
114
|
+
// consumers discard a real value — `sessions-staleness-banner.mjs` returns null
|
|
115
|
+
// for any timestamp named in `_backfill_incomplete_fields`,
|
|
116
|
+
// `session-close-backfill.mjs` keys on `includes('started_at')`. The original
|
|
117
|
+
// spelling survives in the `_<field>_raw` sidecar instead. Two classes because
|
|
118
|
+
// the losses differ: `precision` drops sub-ms digits, `offset` drops nothing
|
|
119
|
+
// but the zone spelling (the instant is kept exactly).
|
|
118
120
|
|
|
119
121
|
/**
|
|
120
122
|
* Timestamp fields `validateSession` holds to `ISO_8601_UTC_MS_RE`
|
|
@@ -124,11 +126,15 @@ const INCOMPLETE_FIELD_ORDER = Object.freeze([
|
|
|
124
126
|
const MS_PRECISION_FIELDS = Object.freeze(['started_at', 'completed_at', 'lease_acquired_at']);
|
|
125
127
|
|
|
126
128
|
/**
|
|
127
|
-
* ISO-8601
|
|
128
|
-
*
|
|
129
|
-
*
|
|
129
|
+
* Strict ISO-8601 timestamp the repair may normalize (#1525): seconds required,
|
|
130
|
+
* fraction optional and of any length, zone exactly `Z` or `±HH:MM`. Anything
|
|
131
|
+
* looser (`+0000`, `+00`, lowercase `z`, no zone) stays unrepaired, so the
|
|
132
|
+
* lenient `Date.parse` never gets to decide what a value meant.
|
|
133
|
+
* Groups: 1 wall-clock date-time to the second, 2 fraction digits, 3 zone,
|
|
134
|
+
* 4 offset sign, 5 offset hours, 6 offset minutes.
|
|
130
135
|
*/
|
|
131
|
-
const
|
|
136
|
+
const ISO_REPAIRABLE_RE =
|
|
137
|
+
/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d+))?(Z|([+-])(\d{2}):(\d{2}))$/;
|
|
132
138
|
|
|
133
139
|
// ---------------------------------------------------------------------------
|
|
134
140
|
// Helpers
|
|
@@ -144,23 +150,45 @@ function isCount(v) {
|
|
|
144
150
|
}
|
|
145
151
|
|
|
146
152
|
/**
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
153
|
+
* Normalize a timestamp to the validator's UTC-millisecond shape (#1442, #1525).
|
|
154
|
+
*
|
|
155
|
+
* - `precision`: a fraction of other than 0 or 3 digits. Sub-millisecond
|
|
156
|
+
* digits are TRUNCATED, never rounded: `.9999Z` must not roll into the next
|
|
157
|
+
* second, and truncation is monotone, so `completed_at >= started_at`
|
|
158
|
+
* survives on every pair it held on. Shorter fractions are zero-padded.
|
|
159
|
+
* - `offset`: any zone spelled other than `Z`. Converting `±HH:MM` to UTC keeps
|
|
160
|
+
* the instant exactly (offsets are whole minutes), so it is lossless and
|
|
161
|
+
* commutes with the truncation above.
|
|
150
162
|
*
|
|
151
163
|
* @param {unknown} value
|
|
152
|
-
* @returns {
|
|
164
|
+
* @returns {null | {invalid: true} | {value: string, precision: boolean, offset: boolean}}
|
|
165
|
+
* null when there is nothing to normalize (not a string, not the strict
|
|
166
|
+
* shape, or already canonical); `{invalid: true}` when the shape matches but
|
|
167
|
+
* the calendar/clock components or the offset are out of range.
|
|
153
168
|
*/
|
|
154
|
-
function
|
|
169
|
+
function normalizeTimestamp(value) {
|
|
155
170
|
if (typeof value !== 'string') return null;
|
|
156
|
-
const m =
|
|
171
|
+
const m = ISO_REPAIRABLE_RE.exec(value);
|
|
157
172
|
if (!m) return null;
|
|
158
|
-
const
|
|
159
|
-
const
|
|
173
|
+
const [, wall, fraction = '', zone, sign, offH, offM] = m;
|
|
174
|
+
const precision = fraction.length !== 0 && fraction.length !== 3;
|
|
175
|
+
const offset = zone !== 'Z';
|
|
176
|
+
if (!precision && !offset) return null; // already what the validator accepts
|
|
177
|
+
const wallCanonical = `${wall}.${fraction.slice(0, 3).padEnd(3, '0')}Z`;
|
|
178
|
+
const wallMs = Date.parse(wallCanonical);
|
|
160
179
|
// Date.parse rolls over e.g. April 31, non-leap February 29 and 24:00.
|
|
161
180
|
// Equality with the original calendar components refuses those repairs.
|
|
162
|
-
if (!Number.isFinite(
|
|
163
|
-
|
|
181
|
+
if (!Number.isFinite(wallMs) || new Date(wallMs).toISOString() !== wallCanonical) {
|
|
182
|
+
return { invalid: true };
|
|
183
|
+
}
|
|
184
|
+
let offsetMin = 0;
|
|
185
|
+
if (offset) {
|
|
186
|
+
if (Number(offH) > 23 || Number(offM) > 59) return { invalid: true };
|
|
187
|
+
offsetMin = (sign === '-' ? -1 : 1) * (Number(offH) * 60 + Number(offM));
|
|
188
|
+
}
|
|
189
|
+
const utc = new Date(wallMs - offsetMin * 60_000);
|
|
190
|
+
if (!Number.isFinite(utc.getTime())) return { invalid: true };
|
|
191
|
+
return { value: utc.toISOString(), precision, offset };
|
|
164
192
|
}
|
|
165
193
|
|
|
166
194
|
function orderIncompleteFields(fields) {
|
|
@@ -377,12 +405,14 @@ export function repairRecord(record) {
|
|
|
377
405
|
// `started_at` — a microsecond `started_at` would otherwise be copied into
|
|
378
406
|
// `completed_at` and spread the defect instead of fixing it.
|
|
379
407
|
let precisionCut = false;
|
|
408
|
+
let offsetNormalized = false;
|
|
380
409
|
for (const key of MS_PRECISION_FIELDS) {
|
|
381
|
-
const
|
|
382
|
-
if (
|
|
410
|
+
const norm = normalizeTimestamp(out[key]);
|
|
411
|
+
if (norm === null || 'invalid' in norm) continue;
|
|
383
412
|
preserveRaw(out, key, record[key], rescued);
|
|
384
|
-
out[key] =
|
|
385
|
-
precisionCut
|
|
413
|
+
out[key] = norm.value;
|
|
414
|
+
precisionCut ||= norm.precision;
|
|
415
|
+
offsetNormalized ||= norm.offset;
|
|
386
416
|
}
|
|
387
417
|
|
|
388
418
|
// -- completed_at ---------------------------------------------------------
|
|
@@ -396,18 +426,20 @@ export function repairRecord(record) {
|
|
|
396
426
|
preserveRaw(out, 'completed_at', record.completed_at, rescued);
|
|
397
427
|
const endedMs = typeof out.ended_at === 'string' ? Date.parse(out.ended_at) : NaN;
|
|
398
428
|
const startedMs = Date.parse(out.started_at);
|
|
399
|
-
const
|
|
429
|
+
const endedNorm = normalizeTimestamp(out.ended_at);
|
|
400
430
|
if (typeof out.ended_at === 'string' &&
|
|
401
|
-
(!Number.isFinite(endedMs) ||
|
|
402
|
-
(ISO_UTC_SUB_MS_RE.test(out.ended_at) && endedTruncated === null))) {
|
|
431
|
+
(!Number.isFinite(endedMs) || (endedNorm !== null && 'invalid' in endedNorm))) {
|
|
403
432
|
// Preserve an invalid legacy measurement for the final schema gate to
|
|
404
433
|
// reject; do not turn it into a valid default or a rolled-over date.
|
|
405
434
|
out.completed_at = out.ended_at;
|
|
406
435
|
} else if (Number.isFinite(endedMs) && Number.isFinite(startedMs) && endedMs >= startedMs) {
|
|
407
436
|
// The COPY is cut to milliseconds; `ended_at` itself is not a validated
|
|
408
437
|
// field and keeps its original bytes, so nothing needs a sidecar here.
|
|
409
|
-
if (
|
|
410
|
-
|
|
438
|
+
if (endedNorm !== null) {
|
|
439
|
+
precisionCut ||= endedNorm.precision;
|
|
440
|
+
offsetNormalized ||= endedNorm.offset;
|
|
441
|
+
}
|
|
442
|
+
out.completed_at = endedNorm?.value ?? out.ended_at;
|
|
411
443
|
} else {
|
|
412
444
|
out.completed_at = out.started_at;
|
|
413
445
|
}
|
|
@@ -415,6 +447,7 @@ export function repairRecord(record) {
|
|
|
415
447
|
incomplete.add('completed_at');
|
|
416
448
|
}
|
|
417
449
|
if (precisionCut) defects.push('timestamp_precision');
|
|
450
|
+
if (offsetNormalized) defects.push('timestamp_offset');
|
|
418
451
|
|
|
419
452
|
if (defects.length === 0) {
|
|
420
453
|
return { record, defects: [], incompleteFields: [], changed: false };
|
|
@@ -252,6 +252,14 @@ export const OPTIONAL_FIELDS = Object.freeze([
|
|
|
252
252
|
// 2026-10-01), so the counters say which of the two it is.
|
|
253
253
|
'cost_records_priced',
|
|
254
254
|
'cost_records_total',
|
|
255
|
+
// #1470 Pkt 4/6 — what `total_cost_usd` is and which price table produced
|
|
256
|
+
// the cost fields. Merged by scripts/emit-session.mjs from
|
|
257
|
+
// scripts/lib/telemetry/pricing.mjs (`COST_BASIS`, `PRICING_TABLE_DATE`):
|
|
258
|
+
// `cost_basis` only beside a written `total_cost_usd`, `pricing_table_date`
|
|
259
|
+
// beside the cost counters. Absent on every record before #1470 — such a
|
|
260
|
+
// cost is not thereby a verified payment either.
|
|
261
|
+
'cost_basis',
|
|
262
|
+
'pricing_table_date',
|
|
255
263
|
// #986 — shape-checked by `_validateOptionalFields` since before this list
|
|
256
264
|
// existed, never declared until the census test made the gap visible.
|
|
257
265
|
'discovery_stats',
|