session-orchestrator 5.10.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.
Files changed (55) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor-plugin/plugin.json +1 -1
  5. package/AGENTS.md +2 -0
  6. package/CHANGELOG.md +32 -0
  7. package/README.md +9 -8
  8. package/SECURITY.md +1 -1
  9. package/agents/code-implementer.md +7 -1
  10. package/docs/agent-authoring.md +2 -0
  11. package/docs/events-schema.md +2 -1
  12. package/docs/fleet-checkin-audit.md +172 -0
  13. package/docs/role-profile-contract.md +139 -0
  14. package/docs/scope-collision-guard.md +3 -0
  15. package/docs/session-config-reference.md +1 -1
  16. package/evals/instruction-ablation/run.mjs +11 -6
  17. package/hooks/_lib/hook-import-set.json +27 -2
  18. package/hooks/_lib/orchestrator-ignore.mjs +112 -0
  19. package/hooks/hooks-codex.json +1 -1
  20. package/hooks/hooks.json +1 -1
  21. package/hooks/on-session-end.mjs +8 -1
  22. package/hooks/on-session-start.mjs +7 -0
  23. package/hooks/post-bash-write-verify.mjs +16 -3
  24. package/hooks/pre-task-scope-disjoint.mjs +12 -0
  25. package/hooks/subagent-telemetry.mjs +8 -3
  26. package/hooks/wave-scope-commit-guard.mjs +4 -1
  27. package/package.json +1 -1
  28. package/rules/opt-in-stack/security-web.md +1 -1
  29. package/scripts/emit-session.mjs +13 -0
  30. package/scripts/fleet-checkin-audit.mjs +138 -0
  31. package/scripts/lib/common.mjs +2 -4
  32. package/scripts/lib/context-overhead-format.mjs +57 -0
  33. package/scripts/lib/fleet-checkin-audit.mjs +244 -0
  34. package/scripts/lib/memory-proposals/sink.mjs +79 -17
  35. package/scripts/lib/memory-proposals/store.mjs +245 -9
  36. package/scripts/lib/project-hygiene.mjs +37 -0
  37. package/scripts/lib/role-profile/schema.mjs +219 -0
  38. package/scripts/lib/session-lock.mjs +57 -10
  39. package/scripts/lib/session-record-repair.mjs +63 -30
  40. package/scripts/lib/session-schema/constants.mjs +8 -0
  41. package/scripts/lib/session-schema/validator.mjs +33 -0
  42. package/scripts/lib/subagents-schema.mjs +9 -0
  43. package/scripts/lib/telemetry/pricing.mjs +11 -0
  44. package/scripts/measure-context-overhead.sh +4 -21
  45. package/scripts/release.mjs +359 -11
  46. package/scripts/run-quality-gate.mjs +47 -0
  47. package/scripts/validate-wave-scope.mjs +59 -6
  48. package/skills/claude-md-drift-check/checker.mjs +8 -6
  49. package/skills/npm-publish/SKILL.md +3 -3
  50. package/skills/release/SKILL.md +2 -2
  51. package/skills/repo-audit/SKILL.md +1 -1
  52. package/skills/session-end/metrics-collection.md +2 -2
  53. package/skills/session-end/session-metrics-write.md +1 -1
  54. package/skills/session-start/references/phase-1-2-session-lock.md +1 -1
  55. package/skills/wave-executor/circuit-breaker.md +1 -0
@@ -8,6 +8,9 @@
8
8
  * `createStateLockExclusive` pattern (POSIX-atomic create-or-fail).
9
9
  * - Proposals JSONL: `.orchestrator/metrics/proposals.jsonl` (O_APPEND)
10
10
  * - Per-wave summary: `.orchestrator/metrics/proposals-summary-<wave-id>.json`
11
+ * - Overflow JSONL: `.orchestrator/metrics/proposals-overflow.jsonl` — validated
12
+ * proposals the quota rejected (#1027 1b). Never counted against the quota,
13
+ * never read by the collector; written under the same lock as the queue.
11
14
  * - Confidence floor check: BEFORE lock acquisition (no I/O, no lock held).
12
15
  * - Quota check: INSIDE lock (count + decide + write serialized).
13
16
  *
@@ -16,6 +19,7 @@
16
19
 
17
20
  import fs from 'node:fs';
18
21
  import path from 'node:path';
22
+ import { randomUUID } from 'node:crypto';
19
23
  import { serializeProposal } from './schema.mjs';
20
24
  import { validatePathInsideProject } from '../path-utils.mjs';
21
25
  import { tryAcquireFileLock, releaseFileLock } from '../file-lock.mjs';
@@ -26,6 +30,8 @@ import { writeJsonAtomicSync } from '../io.mjs';
26
30
  // ---------------------------------------------------------------------------
27
31
 
28
32
  const PROPOSALS_JSONL = '.orchestrator/metrics/proposals.jsonl';
33
+ const PROPOSALS_OVERFLOW_JSONL = '.orchestrator/metrics/proposals-overflow.jsonl';
34
+ const OVERFLOW_MAX_BYTES = 1024 * 1024;
29
35
  const PROPOSALS_LOCK = '.orchestrator/metrics/proposals-write.lock';
30
36
  const PROPOSALS_SUMMARY_PREFIX = '.orchestrator/metrics/proposals-summary-';
31
37
 
@@ -60,20 +66,86 @@ const LOCK_POLL_MS = 50;
60
66
  */
61
67
  function safePath(repoRoot, relPath) {
62
68
  const result = validatePathInsideProject(relPath, repoRoot, { canonicalizeRoot: true });
63
- if (!result.ok) {
64
- const resolved = path.resolve(repoRoot, relPath);
65
- // #548 A5 — surface result.reason ('lexical' | 'symlink' | 'input') in the
66
- // error so operators can distinguish path-traversal classes at a glance:
67
- // - 'lexical' → relPath escaped repoRoot via ../../ before symlink resolution
68
- // - 'symlink' → relPath resolved through a symlink that points outside repoRoot
69
- // - 'input' → relPath was malformed (empty, non-string, null byte)
70
- throw new TypeError(
71
- `store.mjs: path traversal blocked: ${resolved} is outside ${repoRoot} (reason: ${result.reason})`
69
+ // #548 A5 — surface result.reason ('lexical' | 'symlink' | 'input') in the
70
+ // error so operators can distinguish path-traversal classes at a glance:
71
+ // - 'lexical' → relPath escaped repoRoot via ../../ before symlink resolution
72
+ // - 'symlink' → relPath resolved through a symlink that points outside repoRoot
73
+ // - 'input' → relPath was malformed (empty, non-string, null byte)
74
+ const blocked = (reason) =>
75
+ new TypeError(
76
+ `store.mjs: path traversal blocked: ${path.resolve(repoRoot, relPath)} is outside ${repoRoot} (reason: ${reason})`
72
77
  );
78
+ if (!result.ok) throw blocked(result.reason);
79
+ // The shared validator swallows ENOENT on the target and returns the lexical
80
+ // path unchecked, so a symlinked ancestor (or a dangling symlink AT the
81
+ // target) lets the first write land outside the repo (#1027 1b). Closed here,
82
+ // store-side, so the shared validator's other callers keep their behaviour.
83
+ if (result.realPath === undefined && !existingAncestorInsideRoot(repoRoot, result.lexicalPath)) {
84
+ throw blocked('symlink');
73
85
  }
74
86
  return result.realPath ?? result.lexicalPath;
75
87
  }
76
88
 
89
+ /**
90
+ * True when the nearest EXISTING component of `lexicalPath` (the path itself
91
+ * or the closest ancestor) resolves inside the canonical `repoRoot`. A
92
+ * symlink there whose target is missing (dangling) counts as an escape — the
93
+ * write that follows would create the target wherever the link points.
94
+ *
95
+ * Only node:fs/node:path on purpose: store-safepath-reason.test.mjs mocks
96
+ * `@lib/path-utils.mjs` with a single export, so no second import from it.
97
+ *
98
+ * Check-then-write TOCTOU: a link swapped in between this check and the
99
+ * append is not caught (O_NOFOLLOW would narrow the final component only).
100
+ * Accepted under the local-trust model; revisit if the metrics dir ever
101
+ * becomes writable by another principal.
102
+ *
103
+ * @param {string} repoRoot
104
+ * @param {string} lexicalPath — absolute, already known to be lexically inside repoRoot
105
+ * @returns {boolean}
106
+ */
107
+ function existingAncestorInsideRoot(repoRoot, lexicalPath) {
108
+ let root = repoRoot;
109
+ try {
110
+ root = fs.realpathSync(repoRoot);
111
+ } catch (err) {
112
+ if (err?.code !== 'ENOENT' && err?.code !== 'EACCES') throw err;
113
+ }
114
+ const fold = (p) => (process.platform === 'win32' ? p.toLowerCase() : p);
115
+ const inside = (p) => {
116
+ const rel = path.relative(fold(root), fold(p));
117
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
118
+ };
119
+ for (let cur = lexicalPath; ; cur = path.dirname(cur)) {
120
+ // Climbed above the root: root itself is missing, so nothing under it can
121
+ // be a link — the later mkdir creates a plain tree.
122
+ if (!inside(cur)) return true;
123
+ try {
124
+ fs.lstatSync(cur);
125
+ } catch (err) {
126
+ if (err?.code !== 'ENOENT') throw err;
127
+ continue;
128
+ }
129
+ let real;
130
+ try {
131
+ real = fs.realpathSync(cur);
132
+ } catch {
133
+ // Dangling link or loop at the nearest existing component → escape.
134
+ // But the lock file is created and unlinked by concurrent writers all
135
+ // the time: if the component vanished between lstat and realpath it was
136
+ // never a link, so step up to its parent instead of rejecting.
137
+ try {
138
+ fs.lstatSync(cur);
139
+ } catch (err) {
140
+ if (err?.code === 'ENOENT') continue;
141
+ throw err;
142
+ }
143
+ return false;
144
+ }
145
+ return inside(real);
146
+ }
147
+ }
148
+
77
149
  /**
78
150
  * Ensure the parent directory of `filePath` exists.
79
151
  * @param {string} filePath
@@ -100,6 +172,153 @@ function jsonlPathFor(repoRoot) {
100
172
  return safePath(repoRoot, PROPOSALS_JSONL);
101
173
  }
102
174
 
175
+ /**
176
+ * Build the absolute path to the quota-overflow JSONL file.
177
+ * @param {string} repoRoot
178
+ * @returns {string}
179
+ */
180
+ function overflowPathFor(repoRoot) {
181
+ return safePath(repoRoot, PROPOSALS_OVERFLOW_JSONL);
182
+ }
183
+
184
+ /** Flush a directory entry before depending on its persistence. */
185
+ function syncOverflowDirectory(directory) {
186
+ const fd = fs.openSync(directory, 'r');
187
+ try {
188
+ fs.fsyncSync(fd);
189
+ } finally {
190
+ fs.closeSync(fd);
191
+ }
192
+ }
193
+
194
+ /** Create a file without replacing anything, retaining leftovers on failure. */
195
+ function writeOverflowExclusive(file, bytes, mode) {
196
+ const fd = fs.openSync(file, 'wx', mode);
197
+ try {
198
+ // The umask may narrow the creation mode; restore exactly the old mode.
199
+ fs.fchmodSync(fd, mode);
200
+ fs.writeFileSync(fd, bytes);
201
+ fs.fsyncSync(fd);
202
+ } finally {
203
+ fs.closeSync(fd);
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Copy legacy files with bounded memory into an exclusive `.partial` file,
209
+ * then hard-link it to the archive name. An interrupted copy leaves only the
210
+ * `.partial` name behind, never a truncated file under the archive name; the
211
+ * link fails with EEXIST instead of overwriting an existing archive. Unsupported
212
+ * link operations discard only our completed staging copy, leaving active intact.
213
+ */
214
+ function archiveOverflowExclusive(active, archive, partial, mode) {
215
+ const source = fs.openSync(active, 'r');
216
+ try {
217
+ const destination = fs.openSync(partial, 'wx', mode);
218
+ try {
219
+ // The umask may narrow the creation mode; restore exactly the old mode.
220
+ fs.fchmodSync(destination, mode);
221
+ const chunk = Buffer.alloc(64 * 1024);
222
+ let count;
223
+ while ((count = fs.readSync(source, chunk, 0, chunk.length, null)) > 0) {
224
+ fs.writeFileSync(destination, chunk.subarray(0, count));
225
+ }
226
+ fs.fsyncSync(destination);
227
+ } finally {
228
+ fs.closeSync(destination);
229
+ }
230
+ } finally {
231
+ fs.closeSync(source);
232
+ }
233
+ try {
234
+ fs.linkSync(partial, archive);
235
+ } catch (err) {
236
+ if (!['EPERM', 'ENOTSUP', 'EXDEV'].includes(err?.code)) throw err;
237
+ // We exclusively created this complete copy and have not replaced active.
238
+ // Do not accumulate another full copy on every unsupported-link retry.
239
+ // Copy/flush failures and collisions deliberately retain their evidence.
240
+ let cleanupError = '';
241
+ try {
242
+ fs.unlinkSync(partial);
243
+ } catch (cleanup) {
244
+ cleanupError = `; partial cleanup failed (${cleanup?.code ?? 'unknown'}): ${cleanup.message}`;
245
+ }
246
+ // appendProposal exposes message, so preserve the filesystem code there.
247
+ // This statement is specific to publication failure, never post-rename I/O.
248
+ throw new Error(
249
+ `overflow archive publication failed (${err.code}): ${err.message}; new proposal not stored${cleanupError}`,
250
+ { cause: err }
251
+ );
252
+ }
253
+ fs.unlinkSync(partial);
254
+ }
255
+
256
+ /**
257
+ * Preserve the old bytes durably before replacing the active overflow file.
258
+ * The proposals lock serializes writers of this append/rotation decision.
259
+ * Failures before rename leave the active file intact; after rename, the
260
+ * durable archive preserves the old bytes and the replacement has the new
261
+ * record. Incomplete archives/temps remain for inspection, never deleted.
262
+ * BV-004: archive aggregate size is unbounded (no retention authorization).
263
+ * Revisit growth when active file >= 1 MiB or first rotation: measure it.
264
+ */
265
+ function appendOverflow(repoRoot, overflowPath, line) {
266
+ ensureDir(overflowPath);
267
+ let oldSize = 0;
268
+ let oldMode = 0o600;
269
+ let unterminated = false;
270
+ try {
271
+ const fd = fs.openSync(overflowPath, 'r');
272
+ try {
273
+ const oldStat = fs.fstatSync(fd);
274
+ oldSize = oldStat.size;
275
+ oldMode = oldStat.mode & 0o777;
276
+ if (oldSize > 0) {
277
+ const lastByte = Buffer.alloc(1);
278
+ if (fs.readSync(fd, lastByte, 0, 1, oldSize - 1) !== 1) {
279
+ throw new Error('overflow changed while checking its final byte');
280
+ }
281
+ unterminated = lastByte[0] !== 10;
282
+ }
283
+ } finally {
284
+ fs.closeSync(fd);
285
+ }
286
+ } catch (err) {
287
+ if (err?.code !== 'ENOENT') throw err;
288
+ }
289
+ const lineBytes = Buffer.from(line, 'utf8');
290
+ // Optional schema fields have no aggregate size limit. A single oversized
291
+ // record is preserved whole even though the active file exceeds the cap;
292
+ // the next record rotates it. Empty/absent files need no recovery archive.
293
+ if (oldSize === 0 || (!unterminated && oldSize + lineBytes.length <= OVERFLOW_MAX_BYTES)) {
294
+ fs.appendFileSync(overflowPath, lineBytes);
295
+ return;
296
+ }
297
+ const root = fs.realpathSync(repoRoot);
298
+ const directory = path.dirname(overflowPath);
299
+ const token = randomUUID();
300
+ const archive = safePath(root, path.relative(root, `${overflowPath}.archive-${token}.jsonl`));
301
+ const partial = safePath(root, path.relative(root, `${overflowPath}.archive-${token}.partial`));
302
+ const temporary = safePath(root, path.relative(root, `${overflowPath}.tmp-${token}`));
303
+ // Existing arbitrarily large files copy in bounded chunks.
304
+ // Preserve access restrictions; rotation must not widen a private inode's
305
+ // mode through the process umask's default creation permissions.
306
+ archiveOverflowExclusive(overflowPath, archive, partial, oldMode);
307
+ // Persist the archive's name (and the partial's removal) plus any newly
308
+ // created ancestors BEFORE replacing the sole active copy. All these
309
+ // directories are inside root.
310
+ for (let current = directory; ; current = path.dirname(current)) {
311
+ syncOverflowDirectory(current);
312
+ if (current === root) break;
313
+ if (current === path.dirname(current)) {
314
+ throw new Error('overflow directory is not inside the repository root');
315
+ }
316
+ }
317
+ writeOverflowExclusive(temporary, lineBytes, oldMode);
318
+ fs.renameSync(temporary, overflowPath);
319
+ syncOverflowDirectory(directory);
320
+ }
321
+
103
322
  /**
104
323
  * Build the absolute path to the per-wave summary JSON file.
105
324
  * wave_id values are restricted to alphanumerics + hyphens/underscores to
@@ -372,6 +591,23 @@ export async function appendProposal({
372
591
  // Step 3b: Quota check.
373
592
  // ------------------------------------------------------------------
374
593
  if (currentCount >= quotaPerWave) {
594
+ // Keep the validated proposal (#1027 1b): the quota caps what enters the
595
+ // queue, not what an agent found. A failed write is fs-error — never a
596
+ // quota-exceeded that implies the content was preserved. Rotation keeps
597
+ // every old byte in an exclusive archive before replacing the active file.
598
+ let overflowPath;
599
+ try {
600
+ overflowPath = overflowPathFor(repoRoot);
601
+ } catch (err) {
602
+ incrementSummary(summaryPath, 'fs_error'); // traversal still throws, like every store path
603
+ throw err;
604
+ }
605
+ try {
606
+ appendOverflow(repoRoot, overflowPath, serializeProposal(record) + '\n');
607
+ } catch (err) {
608
+ incrementSummary(summaryPath, 'fs_error');
609
+ return { status: 'fs-error', error: err.message };
610
+ }
375
611
  const summary = readSummary(summaryPath);
376
612
  const droppedSoFar = summary.dropped;
377
613
  incrementSummary(summaryPath, 'dropped');
@@ -555,6 +555,10 @@ const AUDIT_VALUE_FLAGS = new Set([
555
555
  const AUDIT_BOOLEAN_FLAGS = new Set([
556
556
  '-r', '--recursive', '-s', '--silent', '-g', '--global', '--workspaces', '--offline',
557
557
  ]);
558
+ // Python tool runners (#1538): subcommand words after the verb, and the runner
559
+ // options that consume the next token.
560
+ const RUNNER_SUBCOMMANDS = { uvx: [], pipx: ['run'], uv: ['tool', 'run'] };
561
+ const RUNNER_VALUE_FLAGS = new Set(['--from', '--spec', '--python', '-p', '--with']);
558
562
 
559
563
  /** Inspect executable job/step locations, never arbitrary keys named run/script. */
560
564
  function hasAuditStep({ text, platform }) {
@@ -683,6 +687,39 @@ function hasAuditSegment(segment, depth = 0) {
683
687
  const trailingArgs = index < 0 ? [] : segment.slice(index);
684
688
  return hasAuditSegment([...tokens, ...trailingArgs], depth + 1);
685
689
  }
690
+ if (verb === 'uvx' || verb === 'pipx' || verb === 'uv') {
691
+ let i = index + 1;
692
+ // `uvx` is an alias of `uv tool run`; `pipx` executes only through `run`.
693
+ for (const word of RUNNER_SUBCOMMANDS[verb]) {
694
+ if (segment[i]?.text !== word) return false;
695
+ i++;
696
+ }
697
+ // Runner options before the tool. Unknown flags count as value-less, so an
698
+ // unlisted value-taking flag makes its value the "tool" and the line is
699
+ // missed — a false finding, never a false pass. Revisit if a real CI line
700
+ // with such a flag is reported as missing its audit.
701
+ // `--from`/`--spec` name the package that provides the tool; another
702
+ // package exposing a `pip-audit` entry point is not the audit.
703
+ let sourcePackage = null;
704
+ while (segment[i]) {
705
+ const { text, redirect } = segment[i];
706
+ // A redirect operand is data, even when it names the audit package.
707
+ if (redirect) return false;
708
+ if (text === '--') { i++; break; }
709
+ if (!text.startsWith('-')) break;
710
+ if (RUNNER_VALUE_FLAGS.has(text)) {
711
+ if (!segment[i + 1] || segment[i + 1].redirect) return false;
712
+ if (text === '--from' || text === '--spec') sourcePackage = segment[i + 1].text;
713
+ i += 2;
714
+ } else {
715
+ i++;
716
+ }
717
+ }
718
+ if (!segment[i] || segment[i].redirect) return false;
719
+ if (segment.slice(index + 1).some((token) => ['-h', '--help', '--version'].includes(token.text))) return false;
720
+ if (sourcePackage !== null && !/^pip-audit(?:\s*(?:[=<>~!]=?|@)\s*[\w.+*-]+)?$/.test(sourcePackage)) return false;
721
+ return /^pip-audit(?:@[\w.+-]+)?$/.test(segment[i].text);
722
+ }
686
723
  if (verb === 'corepack' || verb === 'npx') {
687
724
  index++;
688
725
  while (['-y', '--yes', '--no-install', '--'].includes(segment[index]?.text)) index++;
@@ -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 });