opencode-feature-factory 0.2.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 (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +877 -0
  3. package/assets/agent/backend-builder.md +73 -0
  4. package/assets/agent/codebase-researcher.md +56 -0
  5. package/assets/agent/design-interpreter.md +37 -0
  6. package/assets/agent/frontend-builder.md +74 -0
  7. package/assets/agent/implementation-validator.md +73 -0
  8. package/assets/agent/security-reviewer.md +60 -0
  9. package/assets/agent/spec-writer.md +94 -0
  10. package/assets/agent/story-reader.md +38 -0
  11. package/assets/agent/story-writer.md +39 -0
  12. package/assets/agent/test-verifier.md +73 -0
  13. package/assets/agent/work-decomposer.md +102 -0
  14. package/assets/agent/work-reviewer.md +77 -0
  15. package/assets/command/feature.md +68 -0
  16. package/assets/skills/feature/SCHEMA.md +990 -0
  17. package/assets/skills/feature/SKILL.md +620 -0
  18. package/dist/tui.js +3641 -0
  19. package/package.json +65 -0
  20. package/src/cleanup-sweep-command.js +75 -0
  21. package/src/cleanup-sweep-eligibility.js +581 -0
  22. package/src/cleanup-sweep-output.js +139 -0
  23. package/src/cleanup-sweep-report.js +548 -0
  24. package/src/cleanup-sweep.js +546 -0
  25. package/src/cli-output.js +251 -0
  26. package/src/cli.js +1152 -0
  27. package/src/config.js +231 -0
  28. package/src/cost-attribution.js +327 -0
  29. package/src/cost-report.js +185 -0
  30. package/src/detached-log-supervisor.js +178 -0
  31. package/src/doctor.js +598 -0
  32. package/src/env-snapshot.js +211 -0
  33. package/src/factory-diagnostics.js +429 -0
  34. package/src/factory-paths.js +40 -0
  35. package/src/factory.js +4769 -0
  36. package/src/feature-command-payload.js +378 -0
  37. package/src/git.js +110 -0
  38. package/src/github.js +252 -0
  39. package/src/hardening/atomic-write.js +954 -0
  40. package/src/hardening/line-output.js +139 -0
  41. package/src/hardening/output-policy.js +365 -0
  42. package/src/hardening/process-verification.js +542 -0
  43. package/src/hardening/sensitive-data.js +341 -0
  44. package/src/hardening/terminal-encoding.js +224 -0
  45. package/src/plugin.js +246 -0
  46. package/src/post-pr-ci.js +754 -0
  47. package/src/process-evidence.js +1139 -0
  48. package/src/refs.js +144 -0
  49. package/src/run-state.js +2411 -0
  50. package/src/steering-conflicts.js +77 -0
  51. package/src/telemetry.js +419 -0
  52. package/src/tui-data.js +499 -0
  53. package/src/tui-rendering.js +148 -0
  54. package/src/utils.js +35 -0
  55. package/src/validate.js +1655 -0
  56. package/src/worktrees.js +56 -0
@@ -0,0 +1,1655 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join, relative, resolve, sep } from "node:path";
3
+ import { COST_ATTRIBUTION_SCHEMA_VERSION, COST_ATTRIBUTION_STATUSES, COST_NUMERIC_FIELDS, MAX_COST_ATTRIBUTION_ENTRIES, USAGE_NUMERIC_FIELDS, hasTerminalControl, isSafeCostCurrency, sanitizePublicCostText } from "./cost-attribution.js";
4
+ import { REDACTED_ENV_VALUE, isSensitiveEnvKey, isSensitiveEnvValue } from "./env-snapshot.js";
5
+ import { PROCESS_EVIDENCE_FILE, processEvidenceProcessesDir, validateProcessEvidence } from "./process-evidence.js";
6
+ import { hashFile, hashValue, resolveArtifactRef, resolveEvidenceRef, resolveGateRef, resolveReviewRef, resolveSteeringRef } from "./refs.js";
7
+
8
+ export const TERMINAL_RUN_STATUSES = Object.freeze(["completed", "blocked", "partial", "needs-human"]);
9
+ export const HEARTBEAT_PHASES = Object.freeze([
10
+ "spec-review",
11
+ "decomposition-review",
12
+ "builder-wave",
13
+ "slice-review",
14
+ "test-verifier",
15
+ "test-rerun",
16
+ "test-review",
17
+ "implementation-validator",
18
+ "security-reviewer",
19
+ "remediation",
20
+ "post-pr-observation",
21
+ "post-pr-remediation",
22
+ "post-pr-revalidation",
23
+ ]);
24
+ export const HEARTBEAT_PROTECTED_GATES = Object.freeze(["story", "brief", "pre_pr"]);
25
+ export const MAX_SLICE_DEPENDENCY_WAVES = 3;
26
+
27
+ const RUN_STATUSES = new Set(["running", ...TERMINAL_RUN_STATUSES]);
28
+ const TERMINAL_STATUSES = new Set(TERMINAL_RUN_STATUSES);
29
+ const RUN_MODES = new Set(["interactive", "headless", "autonomous"]);
30
+ const PR_MODES = new Set(["draft", "ready"]);
31
+ const GATE_STATUSES = new Set(["pending", "approved", "changes_requested", "stopped"]);
32
+ const APPROVAL_SOURCES = new Set(["human", "external-driver", "autonomous", "override"]);
33
+ const SAFE_GATE_NAME_PATTERN = /^[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?$/u;
34
+ const SLICE_STATUSES = new Set(["pending", "running", "review", "merged", "blocked"]);
35
+ const STEP_STATUSES = new Set(["running", "accepted", "rejected", "blocked"]);
36
+ const VALIDATOR_VERDICTS = new Set(["GO", "GO-WITH-NITS", "NO-GO"]);
37
+ export const PASSING_VALIDATOR_VERDICTS = new Set(["GO", "GO-WITH-NITS"]);
38
+ const SECURITY_VERDICTS = new Set(["PASS", "BLOCK"]);
39
+ export const PASSING_SECURITY_VERDICTS = new Set(["PASS"]);
40
+ const CONTINUATION_KINDS = new Set(["blocked-run-continuation"]);
41
+ const BLOCKED_CONTINUATION_PARENT_STATUSES = new Set(["blocked"]);
42
+ const HASH_PATTERN = /^sha256:[0-9a-f]{64}$/u;
43
+ const HANDOFF_RECEIPT_KIND = "interactive-approval-handoff";
44
+ const DEBUG_SNAPSHOT_KEYS = new Set(["created_with", "last_resumed_with", "resume_count"]);
45
+ const DEBUG_SNAPSHOT_EVENT_KEYS = new Set(["collected_at", "event", "diagnostic_only", "env"]);
46
+ const COST_ATTRIBUTION_STATUS_SET = new Set(COST_ATTRIBUTION_STATUSES);
47
+ const COST_ATTRIBUTION_ENTRY_OPTIONAL_STRINGS = new Set(["step", "slice_id", "source", "operation", "provider", "model", "request_id", "cost_currency"]);
48
+ const COST_ATTRIBUTION_NUMERIC_FIELDS = new Set([...USAGE_NUMERIC_FIELDS, ...COST_NUMERIC_FIELDS]);
49
+ export const POST_PR_PHASES = Object.freeze(["disabled", "awaiting-pr", "observing", "failure-recording", "remediation-planned", "remediation-running", "changes-observed", "committed", "revalidating", "validated", "push-pending", "remote-confirmed", "succeeded", "blocked", "needs-human"]);
50
+ export const POST_PR_TERMINAL_REASONS = Object.freeze({
51
+ completed: ["post-pr-ci-green", "post-pr-draft-ci-green", "post-pr-external-merge"],
52
+ blocked: ["post-pr-retry-exhausted", "post-pr-observation-timeout", "post-pr-observer-infrastructure", "post-pr-pr-closed"],
53
+ "needs-human": ["post-pr-review-changes-requested", "post-pr-owner-ambiguous", "post-pr-account-switch-failed", "post-pr-head-mismatch", "post-pr-dispatch-start-unknown", "post-pr-path-lane-violation", "post-pr-remote-head-diverged", "post-pr-metadata-unsafe", "post-pr-push-failed", "post-pr-panel-attribution-unsafe"],
54
+ });
55
+ const POST_PR_PHASE_SET = new Set(POST_PR_PHASES);
56
+ const POST_PR_ACTIVE_PHASES = new Set(POST_PR_PHASES.filter((phase) => !["disabled", "awaiting-pr", "succeeded", "blocked", "needs-human"].includes(phase)));
57
+ const FULL_GIT_SHA_PATTERN = /^[0-9a-f]{40}$/u;
58
+
59
+ export class ValidationError extends Error {
60
+ constructor(errors) {
61
+ const safeErrors = errors.map((item) => ({
62
+ ...item,
63
+ path: safeValidationText(item.path),
64
+ message: safeValidationText(item.message),
65
+ }));
66
+ super(safeErrors.map((item) => `${item.path}: ${item.message}`).join("; "));
67
+ this.name = "ValidationError";
68
+ this.errors = safeErrors;
69
+ }
70
+ }
71
+
72
+ export function validateRun(run) {
73
+ const errors = [];
74
+ if (!isRecord(run)) return fail([{ path: "run", message: "must be an object" }]);
75
+
76
+ requiredString(errors, run, "run_id", "run.run_id");
77
+ optionalNumber(errors, run, "schema_version", "run.schema_version");
78
+ optionalEnum(errors, run, "mode", RUN_MODES, "run.mode");
79
+ requiredEnum(errors, run, "status", RUN_STATUSES, "run.status");
80
+ optionalString(errors, run, "created_at", "run.created_at");
81
+ optionalString(errors, run, "updated_at", "run.updated_at");
82
+ optionalString(errors, run, "heartbeat_at", "run.heartbeat_at");
83
+ optionalString(errors, run, "base_ref", "run.base_ref");
84
+ optionalString(errors, run, "base_commit", "run.base_commit");
85
+ optionalString(errors, run, "branch", "run.branch");
86
+ optionalString(errors, run, "worktree", "run.worktree");
87
+ optionalNonEmptyString(errors, run, "github_account", "run.github_account");
88
+ optionalEnum(errors, run, "pr_mode", PR_MODES, "run.pr_mode");
89
+ optionalString(errors, run, "pr_url", "run.pr_url");
90
+ optionalInteger(errors, run, "max_parallel_slices", "run.max_parallel_slices");
91
+ optionalInteger(errors, run, "max_retries", "run.max_retries");
92
+ optionalNonEmptyString(errors, run, "review_tier", "run.review_tier");
93
+ validateDebugSnapshot(errors, run.debug_snapshot, "run.debug_snapshot");
94
+ validateContinuation(errors, run, "run.continuation");
95
+ validateSteering(errors, run.steering, "run.steering");
96
+ validatePostPr(errors, run, "run.post_pr");
97
+
98
+ validateGateMap(errors, run.gates, "run.gates");
99
+ validateRunSlices(errors, run.slices, "run.slices");
100
+ validateCostAttribution(errors, run.cost_attribution, "run.cost_attribution", run);
101
+ validateSteps(errors, run.steps, "run.steps");
102
+ validateVerdict(errors, run.validator, "run.validator", VALIDATOR_VERDICTS);
103
+ validateVerdict(errors, run.security_review, "run.security_review", SECURITY_VERDICTS);
104
+ validateTerminalResult(errors, run, "run.terminal_result");
105
+
106
+ if (errors.length) fail(errors);
107
+ return run;
108
+ }
109
+
110
+ export function validateCostAttributionEntries(entries, runId) {
111
+ const errors = [];
112
+ const path = "run.cost_attribution.entries";
113
+ if (!Array.isArray(entries)) {
114
+ errors.push({ path, message: "must be an array" });
115
+ } else {
116
+ if (entries.length > MAX_COST_ATTRIBUTION_ENTRIES) errors.push({ path, message: `must have at most ${MAX_COST_ATTRIBUTION_ENTRIES} entries` });
117
+ const expectedRunId = stringValue(runId) ? runId : null;
118
+ for (const [index, entry] of entries.entries()) validateCostAttributionEntry(errors, entry, `${path}[${index}]`, null, expectedRunId);
119
+ }
120
+ if (errors.length) fail(errors);
121
+ return entries;
122
+ }
123
+
124
+ export function validateSlicesPlan(plan, { enforceDependencyDepth = true } = {}) {
125
+ const errors = [];
126
+ if (!isRecord(plan)) return fail([{ path: "plan", message: "must be an object" }]);
127
+ if (!Array.isArray(plan.slices)) errors.push({ path: "plan.slices", message: "must be an array" });
128
+ else validatePlannedSlices(errors, plan.slices, "plan.slices", { enforceDependencyDepth });
129
+ if (errors.length) fail(errors);
130
+ return plan;
131
+ }
132
+
133
+ // The dependency-depth cap is grandfathered ONLY for a plan whose durable form is
134
+ // the current `run.slices` — i.e. run.slices is the seeded projection of this exact
135
+ // plan. A merely nonempty run.slices must not exempt the plan: a stale, partial, or
136
+ // unrelated durable slice list would otherwise let the plan be swapped for an
137
+ // over-depth graph unchecked. Match on the closed set of slice ids AND each slice's
138
+ // dependency set (order-insensitive); any divergence re-enables enforcement.
139
+ export function runSlicesMatchPlan(run, plan) {
140
+ const runGraph = normalizeSliceGraph(run?.slices);
141
+ const planGraph = normalizeSliceGraph(plan?.slices);
142
+ if (!runGraph || !planGraph) return false;
143
+ if (runGraph.size === 0 || runGraph.size !== planGraph.size) return false;
144
+ for (const [id, deps] of runGraph) {
145
+ const planDeps = planGraph.get(id);
146
+ if (!planDeps || planDeps.length !== deps.length || planDeps.some((dep, index) => dep !== deps[index])) return false;
147
+ }
148
+ return true;
149
+ }
150
+
151
+ function normalizeSliceGraph(slices) {
152
+ if (!Array.isArray(slices)) return null;
153
+ const graph = new Map();
154
+ for (const slice of slices) {
155
+ if (!isRecord(slice) || !stringValue(slice.id)) return null;
156
+ const id = String(slice.id).trim();
157
+ if (graph.has(id)) return null;
158
+ const deps = Array.isArray(slice.depends_on)
159
+ ? [...new Set(slice.depends_on.filter(stringValue).map((dep) => String(dep).trim()))].sort()
160
+ : [];
161
+ graph.set(id, deps);
162
+ }
163
+ return graph;
164
+ }
165
+
166
+ export function validateHeartbeatState(heartbeat) {
167
+ const errors = [];
168
+ if (!isRecord(heartbeat)) return fail([{ path: "heartbeat", message: "must be an object" }]);
169
+ requiredInteger(errors, heartbeat, "schema_version", "heartbeat.schema_version");
170
+ requiredString(errors, heartbeat, "run_id", "heartbeat.run_id");
171
+ requiredString(errors, heartbeat, "phase", "heartbeat.phase");
172
+ optionalNullableInteger(errors, heartbeat, "pid", "heartbeat.pid");
173
+ requiredInteger(errors, heartbeat, "interval_ms", "heartbeat.interval_ms");
174
+ requiredString(errors, heartbeat, "last_tick_at", "heartbeat.last_tick_at");
175
+ if (errors.length) fail(errors);
176
+ return heartbeat;
177
+ }
178
+
179
+ export function validateFactoryLock(factoryLock) {
180
+ const errors = [];
181
+ if (!isRecord(factoryLock)) return fail([{ path: "factory_lock", message: "must be an object" }]);
182
+ requiredInteger(errors, factoryLock, "schema_version", "factory_lock.schema_version");
183
+ requiredString(errors, factoryLock, "run_id", "factory_lock.run_id");
184
+ optionalString(errors, factoryLock, "session_owner", "factory_lock.session_owner");
185
+ optionalString(errors, factoryLock, "updated_at", "factory_lock.updated_at");
186
+ if (errors.length) fail(errors);
187
+ return factoryLock;
188
+ }
189
+
190
+ export function validateProcessSidecar(processSidecar, options = {}) {
191
+ const validation = validateProcessEvidence(processSidecar, { runDir: options.runDir, runId: options.runId });
192
+ if (!validation.ok) fail([{ path: "process", message: validation.reason }]);
193
+
194
+ const errors = [];
195
+ validateProcessLogRefContainment(errors, validation.evidence.log_ref, "process.log_ref", options.runDir);
196
+ if (errors.length) fail(errors);
197
+ return validation.evidence;
198
+ }
199
+
200
+ export function validateRunDir(runDir) {
201
+ const checks = [];
202
+ const runFile = join(runDir, "run.json");
203
+ let run = null;
204
+ checks.push(validateFile(runFile, (value) => {
205
+ run = validateRun(value);
206
+ return run;
207
+ }));
208
+ const factoryLockPath = join(runDir, "factory.lock");
209
+ if (existsSync(factoryLockPath)) checks.push(validateFile(factoryLockPath, validateFactoryLock));
210
+ const heartbeatPath = join(runDir, "heartbeat.json");
211
+ if (existsSync(heartbeatPath)) checks.push(validateFile(heartbeatPath, validateHeartbeatState));
212
+ const processPath = join(runDir, PROCESS_EVIDENCE_FILE);
213
+ if (existsSync(processPath)) checks.push(validateFile(processPath, (value) => validateProcessSidecar(value, { runDir, runId: run?.run_id })));
214
+ const slicesPath = join(runDir, "plan", "slices.json");
215
+ if (existsSync(slicesPath)) checks.push(validateFile(slicesPath, (value) => validateSlicesPlan(value, { enforceDependencyDepth: !runSlicesMatchPlan(run, value) })));
216
+ if (checks.every((item) => item.ok)) checks.push(...checkRunConsistency(runDir, run).checks);
217
+ return { ok: checks.every((item) => item.ok), checks };
218
+ }
219
+
220
+ export function checkRunConsistency(runDir, run) {
221
+ const checks = [];
222
+ let validRun = null;
223
+ checks.push(runCheck("run.schema", () => {
224
+ validRun = validateRun(run);
225
+ return { run_id: validRun.run_id };
226
+ }));
227
+ if (!validRun) return { ok: false, checks };
228
+
229
+ for (const [gateName, gate] of Object.entries(validRun.gates || {})) {
230
+ if (!isRecord(gate)) continue;
231
+ if (stringValue(gate.question_ref)) checks.push(refCheck(`run.gates.${gateName}.question_ref`, () => resolveGateRef(runDir, gate.question_ref, { mustExist: gate.status !== "pending" })));
232
+ if (stringValue(gate.artifact)) checks.push(refCheck(`run.gates.${gateName}.artifact`, () => resolveArtifactRef(runDir, gate.artifact, { mustExist: gate.status !== "pending" })));
233
+ if (gate.status === "approved") {
234
+ checks.push(runCheck(`run.gates.${gateName}.approved`, () => {
235
+ const errors = [];
236
+ if (!stringValue(gate.answer) && !stringValue(gate.answer_ref)) errors.push({ path: `run.gates.${gateName}.answer`, message: "approved gate requires answer or answer_ref" });
237
+ if (!stringValue(gate.answered_at)) errors.push({ path: `run.gates.${gateName}.answered_at`, message: "approved gate requires answered_at" });
238
+ if (errors.length) fail(errors);
239
+ return { gate: gateName };
240
+ }));
241
+ }
242
+ if (stringValue(gate.answer_ref)) {
243
+ checks.push(refCheck(`run.gates.${gateName}.answer_ref`, () => resolveGateRef(runDir, gate.answer_ref, { mustExist: gate.status !== "pending" })));
244
+ }
245
+ }
246
+
247
+ for (const [index, step] of (Array.isArray(validRun.steps) ? validRun.steps : []).entries()) {
248
+ if (stringValue(step?.evidence_ref)) checks.push(refCheck(`run.steps[${index}].evidence_ref`, () => resolveEvidenceRef(runDir, step.evidence_ref)));
249
+ if (stringValue(step?.review_ref)) checks.push(refCheck(`run.steps[${index}].review_ref`, () => resolveReviewRef(runDir, step.review_ref)));
250
+ if (stringValue(step?.artifact_ref)) checks.push(refCheck(`run.steps[${index}].artifact_ref`, () => resolveArtifactRef(runDir, step.artifact_ref)));
251
+ }
252
+
253
+ for (const [index, slice] of (Array.isArray(validRun.slices) ? validRun.slices : []).entries()) {
254
+ if (stringValue(slice?.evidence_ref)) checks.push(refCheck(`run.slices[${index}].evidence_ref`, () => resolveEvidenceRef(runDir, slice.evidence_ref)));
255
+ if (stringValue(slice?.review_ref)) checks.push(refCheck(`run.slices[${index}].review_ref`, () => resolveReviewRef(runDir, slice.review_ref)));
256
+ if (slice?.status === "merged") {
257
+ checks.push(runCheck(`run.slices[${index}].merged`, () => {
258
+ const errors = [];
259
+ if (!stringValue(slice.merge_commit)) errors.push({ path: `run.slices[${index}].merge_commit`, message: "merged slice requires merge_commit" });
260
+ if (!stringValue(slice.review_ref)) errors.push({ path: `run.slices[${index}].review_ref`, message: "merged slice requires review_ref" });
261
+ if (!stringValue(slice.evidence_ref)) errors.push({ path: `run.slices[${index}].evidence_ref`, message: "merged slice requires evidence_ref" });
262
+ if (errors.length) fail(errors);
263
+ return { slice_id: slice.id };
264
+ }));
265
+ }
266
+ }
267
+
268
+ checks.push(...verdictConsistencyChecks(runDir, validRun, "validator", PASSING_VALIDATOR_VERDICTS));
269
+ checks.push(...verdictConsistencyChecks(runDir, validRun, "security_review", PASSING_SECURITY_VERDICTS));
270
+
271
+ if (stringValue(validRun.pr_url)) {
272
+ checks.push(runCheck("run.pr_url", () => {
273
+ if (validRun.gates?.pre_pr?.status !== "approved") fail([{ path: "run.pr_url", message: "PR URL requires approved pre_pr gate" }]);
274
+ return { pr_url: validRun.pr_url };
275
+ }));
276
+ }
277
+
278
+ checks.push(...steeringConsistencyChecks(runDir, validRun));
279
+ checks.push(...postPrConsistencyChecks(runDir, validRun));
280
+
281
+ return { ok: checks.every((item) => item.ok), checks };
282
+ }
283
+
284
+ export function postPrConsistencyChecks(runDir, run) {
285
+ const postPr = run?.post_pr;
286
+ if (!isRecord(postPr)) return [];
287
+ const checks = [];
288
+ for (const [index, ref] of (Array.isArray(postPr.evidence_refs) ? postPr.evidence_refs : []).entries()) {
289
+ checks.push(refHashCheck(`run.post_pr.evidence_refs[${index}]`, runDir, ref, resolveEvidenceRef));
290
+ }
291
+ const remediation = postPr.remediation;
292
+ if (isRecord(remediation)) {
293
+ for (const [refKey, hashKey, resolver] of [
294
+ ["failure_evidence_ref", "failure_evidence_hash", resolveEvidenceRef],
295
+ ["remediation_evidence_ref", "remediation_evidence_hash", resolveEvidenceRef],
296
+ ["canonical_evidence_ref", "canonical_evidence_hash", resolveEvidenceRef, "revalidation"],
297
+ ["validator_review_ref", "validator_review_hash", resolveReviewRef, "revalidation"],
298
+ ["security_review_ref", "security_review_hash", resolveReviewRef, "revalidation"],
299
+ ]) {
300
+ const owner = argumentsForNestedRef(remediation, refKey, hashKey, resolver);
301
+ if (owner) checks.push(refHashCheck(`run.post_pr.remediation.${owner.prefix}${refKey}`, runDir, owner.value, resolver));
302
+ }
303
+ }
304
+ if (isRecord(postPr.continuation_review)) checks.push(refHashCheck("run.post_pr.continuation_review", runDir, postPr.continuation_review, resolveReviewRef));
305
+ return checks;
306
+ }
307
+
308
+ function argumentsForNestedRef(remediation, refKey, hashKey, resolver) {
309
+ const nested = ["canonical_evidence_ref", "validator_review_ref", "security_review_ref"].includes(refKey);
310
+ const source = nested ? remediation.revalidation : remediation;
311
+ if (!isRecord(source) || !stringValue(source[refKey])) return null;
312
+ return { prefix: nested ? "revalidation." : "", value: { ref: source[refKey], hash: source[hashKey] }, resolver };
313
+ }
314
+
315
+ function refHashCheck(name, runDir, value, resolver) {
316
+ return runCheck(name, () => {
317
+ const resolved = resolver(runDir, value.ref);
318
+ const actualHash = hashFile(resolved.path);
319
+ if (actualHash !== value.hash) fail([{ path: `${name}.hash`, message: "must match referenced file" }]);
320
+ return { ref: resolved.ref, path: resolved.path, hash: actualHash };
321
+ });
322
+ }
323
+
324
+ export function steeringConsistencyChecks(runDir, run) {
325
+ const steering = run.steering;
326
+ if (!isRecord(steering)) return [];
327
+ const checks = [];
328
+ const pending = isRecord(steering.pending) ? steering.pending : null;
329
+ if (pending) {
330
+ checks.push(refCheck("run.steering.pending.ref", () => {
331
+ const resolved = resolveSteeringRef(runDir, pending.ref);
332
+ const actualHash = hashFile(resolved.path, { mode: "raw" });
333
+ if (actualHash !== pending.hash) fail([{ path: "run.steering.pending.hash", message: "must match pending steering file" }]);
334
+ return { ref: resolved.ref, path: resolved.path, hash: actualHash };
335
+ }));
336
+ }
337
+ const uncheckpointed = isRecord(steering.uncheckpointed) ? steering.uncheckpointed : null;
338
+ if (uncheckpointed) {
339
+ checks.push(refCheck("run.steering.uncheckpointed.ref", () => {
340
+ const resolved = resolveSteeringRef(runDir, uncheckpointed.ref);
341
+ const actualHash = hashFile(resolved.path, { mode: "raw" });
342
+ if (actualHash !== uncheckpointed.hash) fail([{ path: "run.steering.uncheckpointed.hash", message: "must match consumed steering file" }]);
343
+ return { ref: resolved.ref, path: resolved.path, hash: actualHash };
344
+ }));
345
+ }
346
+ for (const [index, entry] of (Array.isArray(steering.history) ? steering.history : []).entries()) {
347
+ if (!isRecord(entry) || !stringValue(entry.ref)) continue;
348
+ const mustExist = entry.event === "consumed" || entry.event === "acknowledged";
349
+ checks.push(refCheck(`run.steering.history[${index}].ref`, () => {
350
+ const resolved = resolveSteeringRef(runDir, entry.ref, { mustExist });
351
+ if (mustExist && stringValue(entry.hash)) {
352
+ const actualHash = hashFile(resolved.path, { mode: "raw" });
353
+ if (actualHash !== entry.hash) fail([{ path: `run.steering.history[${index}].hash`, message: "must match steering file" }]);
354
+ return { ref: resolved.ref, path: resolved.path, hash: actualHash };
355
+ }
356
+ return { ref: resolved.ref, path: resolved.path };
357
+ }));
358
+ if (stringValue(entry.source_ref)) {
359
+ checks.push(refCheck(`run.steering.history[${index}].source_ref`, () => resolveSteeringRef(runDir, entry.source_ref, { mustExist: false })));
360
+ }
361
+ }
362
+ return checks;
363
+ }
364
+
365
+ export function validateFile(file, validator) {
366
+ try {
367
+ validator(JSON.parse(readFileSync(file, "utf8")));
368
+ return { path: file, ok: true, errors: [] };
369
+ } catch (error) {
370
+ return { path: file, ok: false, errors: error instanceof ValidationError ? error.errors : [{ path: file, message: error.message }] };
371
+ }
372
+ }
373
+
374
+ export function pendingProtectedGate(run) {
375
+ if (!isRecord(run) || !isRecord(run.gates)) return null;
376
+ for (const gateName of HEARTBEAT_PROTECTED_GATES) if (isPendingGate(run.gates[gateName])) return gateName;
377
+ return null;
378
+ }
379
+
380
+ function verdictConsistencyChecks(runDir, run, key, passingVerdicts) {
381
+ const value = run[key];
382
+ if (!isRecord(value) || !passingVerdicts.has(value.verdict)) return [];
383
+ const checks = [];
384
+ if (stringValue(value.report)) checks.push(refCheck(`run.${key}.report`, () => resolveArtifactRef(runDir, value.report)));
385
+ if (stringValue(value.review_ref)) checks.push(refCheck(`run.${key}.review_ref`, () => resolveReviewRef(runDir, value.review_ref)));
386
+ checks.push(runCheck(`run.${key}.verdict`, () => {
387
+ if (!stringValue(value.report) && !stringValue(value.review_ref)) {
388
+ fail([{ path: `run.${key}`, message: "passing verdict requires report or review_ref" }]);
389
+ }
390
+ return { verdict: value.verdict };
391
+ }));
392
+ return checks;
393
+ }
394
+
395
+ function refCheck(name, resolver) {
396
+ return runCheck(name, () => {
397
+ const resolved = resolver();
398
+ return { ref: resolved.ref, path: resolved.path };
399
+ });
400
+ }
401
+
402
+ function runCheck(name, fn) {
403
+ try {
404
+ return { name, ok: true, errors: [], details: fn() || {} };
405
+ } catch (error) {
406
+ return { name, ok: false, errors: error instanceof ValidationError ? error.errors : [{ path: name, message: error.message }] };
407
+ }
408
+ }
409
+
410
+ function validateGateMap(errors, gates, path) {
411
+ if (gates === undefined || gates === null) return;
412
+ if (!isRecord(gates)) {
413
+ errors.push({ path, message: "must be an object" });
414
+ return;
415
+ }
416
+ for (const [name, gate] of Object.entries(gates)) {
417
+ validateGateName(errors, name, `${path}.${name}`);
418
+ validateGate(errors, gate, `${path}.${name}`, name);
419
+ }
420
+ }
421
+
422
+ function validateDebugSnapshot(errors, snapshotRoot, path) {
423
+ if (snapshotRoot === undefined || snapshotRoot === null) return;
424
+ if (!isRecord(snapshotRoot)) {
425
+ errors.push({ path, message: "must be an object" });
426
+ return;
427
+ }
428
+ for (const key of Object.keys(snapshotRoot)) if (!DEBUG_SNAPSHOT_KEYS.has(key)) errors.push({ path: `${path}.${key}`, message: "is not allowed" });
429
+ validateDebugSnapshotEvent(errors, snapshotRoot.created_with, `${path}.created_with`);
430
+ if (snapshotRoot.last_resumed_with !== undefined && snapshotRoot.last_resumed_with !== null) validateDebugSnapshotEvent(errors, snapshotRoot.last_resumed_with, `${path}.last_resumed_with`);
431
+ requiredInteger(errors, snapshotRoot, "resume_count", `${path}.resume_count`);
432
+ }
433
+
434
+ function validateDebugSnapshotEvent(errors, snapshot, path) {
435
+ if (!isRecord(snapshot)) {
436
+ errors.push({ path, message: "must be an object" });
437
+ return;
438
+ }
439
+ for (const key of Object.keys(snapshot)) if (!DEBUG_SNAPSHOT_EVENT_KEYS.has(key)) errors.push({ path: `${path}.${key}`, message: "is not allowed" });
440
+ requiredString(errors, snapshot, "collected_at", `${path}.collected_at`);
441
+ requiredString(errors, snapshot, "event", `${path}.event`);
442
+ if (snapshot.diagnostic_only !== true) errors.push({ path: `${path}.diagnostic_only`, message: "must equal true" });
443
+ const payload = snapshot.env;
444
+ if (!isRecord(payload)) {
445
+ errors.push({ path: `${path}.env`, message: "must be an object" });
446
+ return;
447
+ }
448
+ validateRedactedEnv(errors, payload, `${path}.env`);
449
+ }
450
+
451
+ function validateContinuation(errors, run, path) {
452
+ const continuation = run.continuation;
453
+ if (continuation === undefined || continuation === null) return;
454
+ if (!isRecord(continuation)) {
455
+ errors.push({ path, message: "must be an object" });
456
+ return;
457
+ }
458
+
459
+ requiredInteger(errors, continuation, "schema_version", `${path}.schema_version`);
460
+ if (Number.isInteger(continuation.schema_version) && continuation.schema_version !== 1) errors.push({ path: `${path}.schema_version`, message: "must equal 1" });
461
+ requiredEnum(errors, continuation, "kind", CONTINUATION_KINDS, `${path}.kind`);
462
+ requiredString(errors, continuation, "created_at", `${path}.created_at`);
463
+ requiredString(errors, continuation, "operator_summary", `${path}.operator_summary`);
464
+ validateContinuationParent(errors, continuation.parent, `${path}.parent`);
465
+ validateContinuationReview(errors, continuation.review, `${path}.review`);
466
+ validateContinuationTarget(errors, run, continuation.target, `${path}.target`);
467
+ validateContinuationRefHashArray(errors, continuation.parent_artifacts, `${path}.parent_artifacts`);
468
+ validateContinuationRefHashArray(errors, continuation.parent_evidence, `${path}.parent_evidence`);
469
+ validateContinuationRefHashArray(errors, continuation.parent_reviews, `${path}.parent_reviews`);
470
+ validateContinuationSelectedReview(errors, continuation, path);
471
+ validateContinuationPlanningReuse(errors, continuation.planning_reuse, `${path}.planning_reuse`);
472
+ validateContinuationPostPr(errors, continuation.post_pr, `${path}.post_pr`);
473
+ }
474
+
475
+ function validateContinuationPlanningReuse(errors, reuse, path) {
476
+ if (reuse === undefined || reuse === null) return;
477
+ if (!isRecord(reuse)) {
478
+ errors.push({ path, message: "must be an object" });
479
+ return;
480
+ }
481
+ if (typeof reuse.eligible !== "boolean") errors.push({ path: `${path}.eligible`, message: "must be a boolean" });
482
+ if (reuse.eligible === true) {
483
+ requiredString(errors, reuse, "spec_review_ref", `${path}.spec_review_ref`);
484
+ requiredHash(errors, reuse, "spec_review_hash", `${path}.spec_review_hash`);
485
+ requiredString(errors, reuse, "spec_artifact_ref", `${path}.spec_artifact_ref`);
486
+ requiredHash(errors, reuse, "spec_artifact_hash", `${path}.spec_artifact_hash`);
487
+ }
488
+ }
489
+
490
+ function validateContinuationPostPr(errors, value, path) {
491
+ if (value === undefined || value === null) return;
492
+ if (!isRecord(value)) { errors.push({ path, message: "must be an object" }); return; }
493
+ allowedKeys(errors, value, new Set(["pr_url", "repository", "pr_number", "head_sha", "disposition", "policy", "post_pr_hash", "evidence_ref", "evidence_hash", "continuation_review_ref", "continuation_review_hash"]), path);
494
+ requiredString(errors, value, "pr_url", `${path}.pr_url`);
495
+ requiredString(errors, value, "repository", `${path}.repository`);
496
+ boundedInteger(errors, value, "pr_number", 1, Number.MAX_SAFE_INTEGER, `${path}.pr_number`);
497
+ requiredFullGitSha(errors, value, "head_sha", `${path}.head_sha`);
498
+ requiredEnum(errors, value, "disposition", new Set(["leave-unchanged"]), `${path}.disposition`);
499
+ validatePostPrPolicy(errors, value.policy, `${path}.policy`);
500
+ for (const key of ["post_pr_hash", "evidence_hash", "continuation_review_hash"]) requiredHash(errors, value, key, `${path}.${key}`);
501
+ for (const key of ["evidence_ref", "continuation_review_ref"]) requiredString(errors, value, key, `${path}.${key}`);
502
+ }
503
+
504
+ function validatePostPr(errors, run, path) {
505
+ const value = run.post_pr;
506
+ if (value === undefined || value === null) return;
507
+ if (!isRecord(value)) {
508
+ errors.push({ path, message: "must be an object" });
509
+ return;
510
+ }
511
+ allowedKeys(errors, value, new Set(["schema_version", "policy", "phase", "attempt", "observation", "remediation", "evidence_refs", "continuation_review", "terminal_fact"]), path);
512
+ requiredInteger(errors, value, "schema_version", `${path}.schema_version`);
513
+ if (value.schema_version !== 1) errors.push({ path: `${path}.schema_version`, message: "must equal 1" });
514
+ validatePostPrPolicy(errors, value.policy, `${path}.policy`);
515
+ requiredEnum(errors, value, "phase", POST_PR_PHASE_SET, `${path}.phase`);
516
+ requiredInteger(errors, value, "attempt", `${path}.attempt`);
517
+ if (Number.isInteger(value.attempt) && value.attempt < 0) errors.push({ path: `${path}.attempt`, message: "must be non-negative" });
518
+ if (Number.isInteger(value.attempt) && Number.isInteger(run.max_retries) && value.attempt > run.max_retries) errors.push({ path: `${path}.attempt`, message: "must not exceed run.max_retries" });
519
+ validatePostPrObservation(errors, value.observation, `${path}.observation`);
520
+ validatePostPrRemediation(errors, value.remediation, `${path}.remediation`);
521
+ validatePostPrRefHashArray(errors, value.evidence_refs, `${path}.evidence_refs`);
522
+ validatePostPrRefHash(errors, value.continuation_review, `${path}.continuation_review`, { optional: true });
523
+ validatePostPrTerminalFact(errors, run, value, `${path}.terminal_fact`);
524
+
525
+ const enabled = value.policy?.enabled === true;
526
+ if (!enabled && value.phase !== "disabled") errors.push({ path: `${path}.phase`, message: "disabled policy requires disabled phase" });
527
+ if (enabled && value.phase === "disabled") errors.push({ path: `${path}.phase`, message: "enabled policy cannot use disabled phase" });
528
+ if (enabled && POST_PR_ACTIVE_PHASES.has(value.phase)) {
529
+ if (run.status !== "running") errors.push({ path: "run.status", message: `must be running while post-PR phase is ${value.phase}` });
530
+ if (!stringValue(run.pr_url)) errors.push({ path: "run.pr_url", message: `is required while post-PR phase is ${value.phase}` });
531
+ if (run.terminal_result !== undefined && run.terminal_result !== null) errors.push({ path: "run.terminal_result", message: "must be null during active post-PR state" });
532
+ }
533
+ if (["observing", "remote-confirmed", "succeeded"].includes(value.phase) && !isRecord(value.observation)) errors.push({ path: `${path}.observation`, message: `is required for ${value.phase}` });
534
+ if (isRecord(value.observation) && isRecord(value.policy)) {
535
+ if (value.observation.current_interval_ms < value.policy.initial_poll_ms || value.observation.current_interval_ms > value.policy.max_poll_ms) errors.push({ path: `${path}.observation.current_interval_ms`, message: "must stay within persisted poll policy" });
536
+ if (value.observation.consecutive_transient_errors > value.policy.max_transient_errors) errors.push({ path: `${path}.observation.consecutive_transient_errors`, message: "must not exceed persisted transient error budget" });
537
+ }
538
+ if (["failure-recording", "remediation-planned", "remediation-running", "changes-observed", "committed", "revalidating", "validated", "push-pending", "remote-confirmed"].includes(value.phase) && !isRecord(value.remediation)) errors.push({ path: `${path}.remediation`, message: `is required for ${value.phase}` });
539
+ if (isRecord(value.remediation) && value.remediation.attempt !== value.attempt) errors.push({ path: `${path}.remediation.attempt`, message: "must equal run.post_pr.attempt" });
540
+ if (isRecord(value.remediation)) {
541
+ const expectedStage = new Map([["remediation-planned", "planned"], ["remediation-running", "running"], ["changes-observed", "changes-observed"], ["committed", "committed"], ["revalidating", "revalidating"], ["validated", "validated"], ["push-pending", "push-pending"], ["remote-confirmed", "remote-confirmed"]]).get(value.phase);
542
+ if (expectedStage && value.remediation.stage !== expectedStage) errors.push({ path: `${path}.remediation.stage`, message: `must be ${expectedStage} while phase is ${value.phase}` });
543
+ const boundFailure = Array.isArray(value.evidence_refs) && value.evidence_refs.some((item) => item?.ref === value.remediation.failure_evidence_ref && item?.hash === value.remediation.failure_evidence_hash);
544
+ if (!boundFailure) errors.push({ path: `${path}.evidence_refs`, message: "must bind the current failure evidence ref/hash" });
545
+ }
546
+ if (isRecord(value.continuation_review) && !(value.phase === "blocked" && run.terminal_result?.reason === "post-pr-retry-exhausted")) errors.push({ path: `${path}.continuation_review`, message: "is allowed only for retry exhaustion" });
547
+ validatePostPrTerminalConsistency(errors, run, value, path);
548
+ }
549
+
550
+ function validatePostPrTerminalFact(errors, run, postPr, path) {
551
+ const fact = postPr.terminal_fact;
552
+ const reason = run.terminal_result?.reason;
553
+ const expectedKinds = new Map([
554
+ ["post-pr-account-switch-failed", "account-switch-failed"],
555
+ ["post-pr-dispatch-start-unknown", "dispatch-start-unknown"],
556
+ ["post-pr-path-lane-violation", "path-lane-violation"],
557
+ ["post-pr-remote-head-diverged", "remote-head-diverged"],
558
+ ["post-pr-push-failed", "push-failed"],
559
+ ["post-pr-panel-attribution-unsafe", "panel-attribution-unsafe"],
560
+ ]);
561
+ const expectedKind = expectedKinds.get(reason) || (reason === "post-pr-metadata-unsafe" && fact?.kind === "panel-runner-result-malformed" ? "panel-runner-result-malformed" : null);
562
+ if (fact === undefined || fact === null) {
563
+ if (expectedKind) errors.push({ path, message: `is required for ${reason}` });
564
+ return;
565
+ }
566
+ if (!isRecord(fact)) { errors.push({ path, message: "must be an object or null" }); return; }
567
+ if (!expectedKind) { errors.push({ path, message: "is allowed only for fact-bound post-PR terminal reasons" }); return; }
568
+ requiredInteger(errors, fact, "schema_version", `${path}.schema_version`);
569
+ if (fact.schema_version !== 1) errors.push({ path: `${path}.schema_version`, message: "must equal 1" });
570
+ requiredEnum(errors, fact, "kind", new Set([expectedKind]), `${path}.kind`);
571
+ requiredString(errors, fact, "observed_at", `${path}.observed_at`);
572
+ if (!Number.isFinite(Date.parse(fact.observed_at || ""))) errors.push({ path: `${path}.observed_at`, message: "must be an ISO timestamp" });
573
+ const remediation = postPr.remediation;
574
+ if (expectedKind === "account-switch-failed") {
575
+ const pushPhase = fact.operation !== "gh-auth-switch";
576
+ allowedKeys(errors, fact, pushPhase
577
+ ? new Set(["schema_version", "kind", "observed_at", "attempt", "operation", "error_class", "exit_code", "classification", "error_count", "error_limit", "expected_remote_sha", "candidate_head_sha", "next_retry_at"])
578
+ : new Set(["schema_version", "kind", "observed_at", "operation", "github_account", "error_class", "exit_code"]), path);
579
+ requiredEnum(errors, fact, "operation", pushPhase ? new Set(["remote-head", "fast-forward-push", "remote-confirmation"]) : new Set(["gh-auth-switch"]), `${path}.operation`);
580
+ requiredEnum(errors, fact, "error_class", new Set(["account-auth", "permission", "not-found", "protocol", "command"]), `${path}.error_class`);
581
+ if (fact.exit_code !== null) boundedInteger(errors, fact, "exit_code", 0, 255, `${path}.exit_code`);
582
+ if (pushPhase) {
583
+ boundedInteger(errors, fact, "attempt", 1, Number.MAX_SAFE_INTEGER, `${path}.attempt`);
584
+ requiredEnum(errors, fact, "classification", new Set(["permanent"]), `${path}.classification`);
585
+ boundedInteger(errors, fact, "error_count", 1, Number.MAX_SAFE_INTEGER, `${path}.error_count`);
586
+ boundedInteger(errors, fact, "error_limit", 1, Number.MAX_SAFE_INTEGER, `${path}.error_limit`);
587
+ for (const key of ["expected_remote_sha", "candidate_head_sha"]) requiredFullGitSha(errors, fact, key, `${path}.${key}`);
588
+ if (fact.next_retry_at !== null || fact.operation !== remediation?.push?.last_error?.operation) errors.push({ path, message: "must bind the persisted push account failure exactly" });
589
+ } else {
590
+ requiredString(errors, fact, "github_account", `${path}.github_account`);
591
+ if (fact.github_account !== run.github_account) errors.push({ path: `${path}.github_account`, message: "must match run.github_account" });
592
+ if (fact.error_class !== postPr.observation?.last_error?.class || fact.exit_code !== (postPr.observation?.last_error?.exit_code ?? null) || fact.observed_at !== postPr.observation?.last_error?.occurred_at) errors.push({ path, message: "must bind the persisted account-switch error exactly" });
593
+ }
594
+ } else if (expectedKind === "dispatch-start-unknown") {
595
+ allowedKeys(errors, fact, new Set(["schema_version", "kind", "observed_at", "attempt", "activity", "dispatch_id", "dispatch_started_at", "candidate_head_sha", "outcome"]), path);
596
+ boundedInteger(errors, fact, "attempt", 1, Number.MAX_SAFE_INTEGER, `${path}.attempt`);
597
+ requiredString(errors, fact, "dispatch_id", `${path}.dispatch_id`);
598
+ requiredString(errors, fact, "dispatch_started_at", `${path}.dispatch_started_at`);
599
+ if (fact.activity !== undefined) requiredEnum(errors, fact, "activity", new Set(["remediation", "canonical", "validator", "security"]), `${path}.activity`);
600
+ if (fact.candidate_head_sha !== undefined && fact.candidate_head_sha !== null) requiredFullGitSha(errors, fact, "candidate_head_sha", `${path}.candidate_head_sha`);
601
+ requiredEnum(errors, fact, "outcome", new Set(["return-unknown"]), `${path}.outcome`);
602
+ const dispatch = fact.activity && fact.activity !== "remediation" ? remediation?.revalidation?.jobs?.[fact.activity] : remediation?.dispatch;
603
+ const dispatchId = fact.activity && fact.activity !== "remediation" ? dispatch?.dispatch_id : dispatch?.id;
604
+ if (fact.attempt !== postPr.attempt || fact.dispatch_id !== dispatchId || fact.dispatch_started_at !== dispatch?.started_at || dispatch?.status !== "running") errors.push({ path, message: "must bind the running dispatch identity exactly" });
605
+ } else if (expectedKind === "path-lane-violation") {
606
+ allowedKeys(errors, fact, new Set(["schema_version", "kind", "observed_at", "attempt", "lane", "source", "violation", "path_b64url", "changes_hash"]), path);
607
+ boundedInteger(errors, fact, "attempt", 1, Number.MAX_SAFE_INTEGER, `${path}.attempt`);
608
+ requiredEnum(errors, fact, "lane", new Set(["slice", "test"]), `${path}.lane`);
609
+ requiredEnum(errors, fact, "source", new Set(["remediation-diff"]), `${path}.source`);
610
+ requiredEnum(errors, fact, "violation", new Set(["outside-lane", "unsafe-change-kind", "symlink-escape"]), `${path}.violation`);
611
+ requiredString(errors, fact, "path_b64url", `${path}.path_b64url`);
612
+ if (stringValue(fact.path_b64url) && !/^[A-Za-z0-9_-]+$/u.test(fact.path_b64url)) errors.push({ path: `${path}.path_b64url`, message: "must be canonical base64url" });
613
+ const decodedPath = decodeCanonicalBase64url(fact.path_b64url);
614
+ if (stringValue(fact.path_b64url) && decodedPath === null) errors.push({ path: `${path}.path_b64url`, message: "must encode valid UTF-8 path bytes canonically" });
615
+ requiredHash(errors, fact, "changes_hash", `${path}.changes_hash`);
616
+ if (fact.attempt !== postPr.attempt || fact.lane !== remediation?.lane || fact.changes_hash !== hashValueForValidation(remediation?.changes)) errors.push({ path, message: "must bind the remediation lane and changed paths exactly" });
617
+ if (decodedPath !== null && !remediation?.changes?.paths?.includes(decodedPath)) errors.push({ path: `${path}.path_b64url`, message: "must identify a persisted changed path" });
618
+ } else if (expectedKind === "remote-head-diverged") {
619
+ allowedKeys(errors, fact, new Set(["schema_version", "kind", "observed_at", "attempt", "expected_remote_sha", "candidate_head_sha", "observed_remote_sha"]), path);
620
+ boundedInteger(errors, fact, "attempt", 1, Number.MAX_SAFE_INTEGER, `${path}.attempt`);
621
+ for (const key of ["expected_remote_sha", "candidate_head_sha", "observed_remote_sha"]) requiredFullGitSha(errors, fact, key, `${path}.${key}`);
622
+ if (fact.attempt !== postPr.attempt || fact.expected_remote_sha !== remediation?.push?.remote_before_sha || fact.candidate_head_sha !== remediation?.candidate_head_sha) errors.push({ path, message: "must bind the push-pending remote and candidate heads exactly" });
623
+ if (fact.observed_remote_sha === fact.expected_remote_sha || fact.observed_remote_sha === fact.candidate_head_sha) errors.push({ path: `${path}.observed_remote_sha`, message: "must differ from both expected remote and candidate heads" });
624
+ } else if (expectedKind === "panel-runner-result-malformed") {
625
+ allowedKeys(errors, fact, new Set(["schema_version", "kind", "observed_at", "attempt", "activity", "dispatch_id", "candidate_head_sha", "issue"]), path);
626
+ boundedInteger(errors, fact, "attempt", 1, Number.MAX_SAFE_INTEGER, `${path}.attempt`);
627
+ requiredEnum(errors, fact, "activity", new Set(["validator", "security"]), `${path}.activity`);
628
+ requiredString(errors, fact, "dispatch_id", `${path}.dispatch_id`);
629
+ requiredFullGitSha(errors, fact, "candidate_head_sha", `${path}.candidate_head_sha`);
630
+ requiredEnum(errors, fact, "issue", new Set(["non-object", "missing-verdict", "unexpected-result-keys", "invalid-verdict"]), `${path}.issue`);
631
+ const job = remediation?.revalidation?.jobs?.[fact.activity];
632
+ if (fact.attempt !== postPr.attempt || fact.candidate_head_sha !== remediation?.candidate_head_sha || fact.dispatch_id !== job?.dispatch_id || job?.status !== "running") errors.push({ path, message: "must bind the running panel job exactly" });
633
+ } else if (expectedKind === "push-failed") {
634
+ allowedKeys(errors, fact, new Set(["schema_version", "kind", "observed_at", "attempt", "operation", "error_class", "exit_code", "classification", "error_count", "error_limit", "expected_remote_sha", "candidate_head_sha", "next_retry_at"]), path);
635
+ boundedInteger(errors, fact, "attempt", 1, Number.MAX_SAFE_INTEGER, `${path}.attempt`);
636
+ requiredEnum(errors, fact, "operation", new Set(["remote-head", "fast-forward-push", "remote-confirmation"]), `${path}.operation`);
637
+ requiredEnum(errors, fact, "error_class", new Set(["timeout", "network", "rate-limit", "server", "account-auth", "permission", "not-found", "protocol", "command", "non-fast-forward"]), `${path}.error_class`);
638
+ if (fact.exit_code !== null) boundedInteger(errors, fact, "exit_code", 0, 255, `${path}.exit_code`);
639
+ requiredEnum(errors, fact, "classification", new Set(["permanent", "exhausted"]), `${path}.classification`);
640
+ boundedInteger(errors, fact, "error_count", 1, Number.MAX_SAFE_INTEGER, `${path}.error_count`);
641
+ boundedInteger(errors, fact, "error_limit", 1, Number.MAX_SAFE_INTEGER, `${path}.error_limit`);
642
+ for (const key of ["expected_remote_sha", "candidate_head_sha"]) requiredFullGitSha(errors, fact, key, `${path}.${key}`);
643
+ if (fact.next_retry_at !== null) errors.push({ path: `${path}.next_retry_at`, message: "must be null for terminal push failures" });
644
+ if (fact.attempt !== postPr.attempt || fact.error_count !== remediation?.push?.consecutive_transient_errors || fact.error_limit !== postPr.policy?.max_transient_errors || fact.operation !== remediation?.push?.last_error?.operation) errors.push({ path, message: "must bind the persisted push failure exactly" });
645
+ } else if (expectedKind === "panel-attribution-unsafe") {
646
+ allowedKeys(errors, fact, new Set(["schema_version", "kind", "observed_at", "attempt", "candidate_head_sha", "panel", "category", "affected_paths_hash"]), path);
647
+ boundedInteger(errors, fact, "attempt", 1, Number.MAX_SAFE_INTEGER, `${path}.attempt`);
648
+ requiredFullGitSha(errors, fact, "candidate_head_sha", `${path}.candidate_head_sha`);
649
+ requiredEnum(errors, fact, "panel", new Set(["validator", "security", "combined"]), `${path}.panel`);
650
+ requiredEnum(errors, fact, "category", new Set(["missing-paths", "invalid-paths", "empty-paths", "mixed-owner", "unowned-path", "owner-conflict", "security-block-without-slice-owner"]), `${path}.category`);
651
+ requiredBareSha256(errors, fact, "affected_paths_hash", `${path}.affected_paths_hash`);
652
+ if (fact.attempt !== postPr.attempt || fact.candidate_head_sha !== remediation?.candidate_head_sha) errors.push({ path, message: "must bind the current panel candidate exactly" });
653
+ }
654
+ }
655
+
656
+ function hashValueForValidation(value) {
657
+ return value === undefined ? null : hashValue(value);
658
+ }
659
+
660
+ function requiredBareSha256(errors, object, key, path) {
661
+ if (typeof object?.[key] !== "string" || !/^[0-9a-f]{64}$/u.test(object[key])) errors.push({ path, message: "must be a bare lowercase SHA-256 digest" });
662
+ }
663
+
664
+ function decodeCanonicalBase64url(value) {
665
+ if (!stringValue(value) || !/^[A-Za-z0-9_-]+$/u.test(value)) return null;
666
+ const bytes = Buffer.from(value, "base64url");
667
+ const decoded = bytes.toString("utf8");
668
+ return Buffer.from(decoded, "utf8").toString("base64url") === value ? decoded : null;
669
+ }
670
+
671
+ function validatePostPrPolicy(errors, policy, path) {
672
+ if (!isRecord(policy)) {
673
+ errors.push({ path, message: "must be an object" });
674
+ return;
675
+ }
676
+ allowedKeys(errors, policy, new Set(["enabled", "wait_ms", "initial_poll_ms", "max_poll_ms", "check_start_grace_ms", "max_transient_errors", "review"]), path);
677
+ requiredBoolean(errors, policy, "enabled", `${path}.enabled`);
678
+ boundedInteger(errors, policy, "wait_ms", 1_800_000, 86_400_000, `${path}.wait_ms`);
679
+ boundedInteger(errors, policy, "initial_poll_ms", 15_000, 300_000, `${path}.initial_poll_ms`);
680
+ boundedInteger(errors, policy, "max_poll_ms", 15_000, 600_000, `${path}.max_poll_ms`);
681
+ boundedInteger(errors, policy, "check_start_grace_ms", 60_000, 900_000, `${path}.check_start_grace_ms`);
682
+ boundedInteger(errors, policy, "max_transient_errors", 1, 50, `${path}.max_transient_errors`);
683
+ if (Number.isInteger(policy.initial_poll_ms) && Number.isInteger(policy.max_poll_ms) && policy.max_poll_ms < policy.initial_poll_ms) errors.push({ path: `${path}.max_poll_ms`, message: "must be greater than or equal to initial_poll_ms" });
684
+ const review = policy.review;
685
+ if (!isRecord(review)) errors.push({ path: `${path}.review`, message: "must be an object" });
686
+ else {
687
+ allowedKeys(errors, review, new Set(["required", "reviewer_login", "source"]), `${path}.review`);
688
+ requiredBoolean(errors, review, "required", `${path}.review.required`);
689
+ requiredEnum(errors, review, "source", new Set(["driver", "none"]), `${path}.review.source`);
690
+ if (review.required === true) {
691
+ requiredString(errors, review, "reviewer_login", `${path}.review.reviewer_login`);
692
+ if (stringValue(review.reviewer_login) && !/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/u.test(review.reviewer_login)) errors.push({ path: `${path}.review.reviewer_login`, message: "must be a valid GitHub login" });
693
+ if (review.source !== "driver") errors.push({ path: `${path}.review.source`, message: "required review must use driver source" });
694
+ } else if (review.reviewer_login !== null) errors.push({ path: `${path}.review.reviewer_login`, message: "must be null when review is not required" });
695
+ }
696
+ }
697
+
698
+ function validatePostPrObservation(errors, observation, path) {
699
+ if (observation === undefined || observation === null) return;
700
+ if (!isRecord(observation)) {
701
+ errors.push({ path, message: "must be an object or null" });
702
+ return;
703
+ }
704
+ allowedKeys(errors, observation, new Set(["epoch", "expected_head_sha", "started_at", "deadline_at", "next_poll_at", "poll_count", "unchanged_count", "current_interval_ms", "consecutive_transient_errors", "last_observed_at", "last_fingerprint", "last_check_verdict", "last_review_verdict", "last_verdict", "last_error", "review_request", "snapshot"]), path);
705
+ boundedInteger(errors, observation, "epoch", 1, Number.MAX_SAFE_INTEGER, `${path}.epoch`);
706
+ requiredFullGitSha(errors, observation, "expected_head_sha", `${path}.expected_head_sha`);
707
+ for (const key of ["started_at", "deadline_at", "next_poll_at"]) requiredString(errors, observation, key, `${path}.${key}`);
708
+ for (const key of ["poll_count", "unchanged_count", "consecutive_transient_errors"]) boundedInteger(errors, observation, key, 0, Number.MAX_SAFE_INTEGER, `${path}.${key}`);
709
+ boundedInteger(errors, observation, "current_interval_ms", 1, 600_000, `${path}.current_interval_ms`);
710
+ for (const key of ["last_observed_at", "last_fingerprint"]) optionalNullableString(errors, observation, key, `${path}.${key}`);
711
+ requiredEnum(errors, observation, "last_check_verdict", new Set(["not_started", "not_applicable", "pending", "pass", "red", "indeterminate"]), `${path}.last_check_verdict`);
712
+ requiredEnum(errors, observation, "last_review_verdict", new Set(["not_required", "pending", "pass", "red", "indeterminate", "deferred"]), `${path}.last_review_verdict`);
713
+ requiredEnum(errors, observation, "last_verdict", new Set(["pending", "green", "red", "external-merge", "closed", "head-mismatch", "infrastructure"]), `${path}.last_verdict`);
714
+ validatePostPrLastError(errors, observation.last_error, `${path}.last_error`);
715
+ validatePostPrReviewRequest(errors, observation.review_request, `${path}.review_request`);
716
+ if (observation.snapshot !== undefined && observation.snapshot !== null) validatePostPrSanitizedSnapshot(errors, observation.snapshot, `${path}.snapshot`);
717
+ const started = Date.parse(observation.started_at || "");
718
+ const deadline = Date.parse(observation.deadline_at || "");
719
+ const nextPoll = Date.parse(observation.next_poll_at || "");
720
+ if (Number.isFinite(started) && Number.isFinite(deadline) && deadline <= started) errors.push({ path: `${path}.deadline_at`, message: "must be after started_at" });
721
+ if (Number.isFinite(deadline) && Number.isFinite(nextPoll) && nextPoll > deadline) errors.push({ path: `${path}.next_poll_at`, message: "must not exceed deadline_at" });
722
+ }
723
+
724
+ function validatePostPrLastError(errors, value, path) {
725
+ if (value === undefined || value === null) return;
726
+ if (!isRecord(value)) { errors.push({ path, message: "must be an object or null" }); return; }
727
+ allowedKeys(errors, value, new Set(["class", "exit_code", "occurred_at", "next_retry_at"]), path);
728
+ requiredEnum(errors, value, "class", new Set(["timeout", "network", "rate-limit", "server", "account-auth", "permission", "not-found", "protocol", "command"]), `${path}.class`);
729
+ if (value.exit_code !== undefined) optionalNullableInteger(errors, value, "exit_code", `${path}.exit_code`);
730
+ requiredString(errors, value, "occurred_at", `${path}.occurred_at`);
731
+ optionalNullableString(errors, value, "next_retry_at", `${path}.next_retry_at`);
732
+ }
733
+
734
+ function validatePostPrReviewRequest(errors, value, path) {
735
+ if (value === undefined || value === null) return;
736
+ if (!isRecord(value)) { errors.push({ path, message: "must be an object or null" }); return; }
737
+ allowedKeys(errors, value, new Set(["status", "attempts", "requested_at"]), path);
738
+ requiredEnum(errors, value, "status", new Set(["pending", "requested"]), `${path}.status`);
739
+ boundedInteger(errors, value, "attempts", 0, Number.MAX_SAFE_INTEGER, `${path}.attempts`);
740
+ optionalNullableString(errors, value, "requested_at", `${path}.requested_at`);
741
+ if (value.status === "requested" && !stringValue(value.requested_at)) errors.push({ path: `${path}.requested_at`, message: "is required after reviewer request" });
742
+ }
743
+
744
+ function validatePostPrSanitizedSnapshot(errors, value, path) {
745
+ if (Array.isArray(value)) {
746
+ for (const [index, item] of value.entries()) validatePostPrSanitizedSnapshot(errors, item, `${path}[${index}]`);
747
+ return;
748
+ }
749
+ if (!isRecord(value)) return;
750
+ for (const [key, item] of Object.entries(value)) {
751
+ if (/^(?:raw|body|stdout|stderr|headers?|token|credentials?)$/iu.test(key)) errors.push({ path: `${path}.${key}`, message: "untrusted raw or sensitive data is not allowed" });
752
+ validatePostPrSanitizedSnapshot(errors, item, `${path}.${key}`);
753
+ }
754
+ }
755
+
756
+ function validatePostPrRemediation(errors, remediation, path) {
757
+ if (remediation === undefined || remediation === null) return;
758
+ if (!isRecord(remediation)) {
759
+ errors.push({ path, message: "must be an object or null" });
760
+ return;
761
+ }
762
+ allowedKeys(errors, remediation, new Set(["schema_version", "attempt", "reason_code", "failure_fingerprint", "failed_head_sha", "failure_evidence_ref", "failure_evidence_hash", "owner", "route", "lane", "stage", "baseline_head_sha", "dispatch", "changes", "candidate_head_sha", "remediation_evidence_ref", "remediation_evidence_hash", "revalidation", "push"]), path);
763
+ requiredInteger(errors, remediation, "schema_version", `${path}.schema_version`);
764
+ if (remediation.schema_version !== 1) errors.push({ path: `${path}.schema_version`, message: "must equal 1" });
765
+ boundedInteger(errors, remediation, "attempt", 1, Number.MAX_SAFE_INTEGER, `${path}.attempt`);
766
+ requiredEnum(errors, remediation, "reason_code", new Set(["check-red", "local-red"]), `${path}.reason_code`);
767
+ requiredHash(errors, remediation, "failure_fingerprint", `${path}.failure_fingerprint`);
768
+ requiredFullGitSha(errors, remediation, "failed_head_sha", `${path}.failed_head_sha`);
769
+ requiredString(errors, remediation, "failure_evidence_ref", `${path}.failure_evidence_ref`);
770
+ requiredHash(errors, remediation, "failure_evidence_hash", `${path}.failure_evidence_hash`);
771
+ validatePostPrOwner(errors, remediation.owner, `${path}.owner`, remediation.route, remediation.lane);
772
+ requiredEnum(errors, remediation, "route", new Set(["backend-builder", "frontend-builder", "test-verifier"]), `${path}.route`);
773
+ requiredEnum(errors, remediation, "lane", new Set(["slice", "test"]), `${path}.lane`);
774
+ requiredEnum(errors, remediation, "stage", new Set(["planned", "running", "changes-observed", "committed", "revalidating", "validated", "push-pending", "remote-confirmed"]), `${path}.stage`);
775
+ requiredFullGitSha(errors, remediation, "baseline_head_sha", `${path}.baseline_head_sha`);
776
+ validatePostPrDispatch(errors, remediation.dispatch, `${path}.dispatch`, remediation);
777
+ validatePostPrChanges(errors, remediation.changes, `${path}.changes`);
778
+ optionalNullableFullGitSha(errors, remediation, "candidate_head_sha", `${path}.candidate_head_sha`);
779
+ optionalNullableString(errors, remediation, "remediation_evidence_ref", `${path}.remediation_evidence_ref`);
780
+ optionalNullableHash(errors, remediation, "remediation_evidence_hash", `${path}.remediation_evidence_hash`);
781
+ if ((remediation.remediation_evidence_ref === null) !== (remediation.remediation_evidence_hash === null)) errors.push({ path, message: "remediation evidence ref/hash must be set together" });
782
+ validatePostPrRevalidation(errors, remediation.revalidation, `${path}.revalidation`);
783
+ validatePostPrPush(errors, remediation.push, `${path}.push`);
784
+ if (stringValue(remediation.candidate_head_sha) && remediation.candidate_head_sha === remediation.failed_head_sha) errors.push({ path: `${path}.candidate_head_sha`, message: "must differ from failed_head_sha" });
785
+ if (["validated", "push-pending", "remote-confirmed"].includes(remediation.stage)) validatePostPrValidated(errors, remediation, path);
786
+ if (remediation.stage === "push-pending" && remediation.push?.local_head_sha !== remediation.candidate_head_sha) errors.push({ path: `${path}.push.local_head_sha`, message: "must equal candidate_head_sha while push is pending" });
787
+ if (remediation.stage === "remote-confirmed" && (remediation.push?.status !== "confirmed" || remediation.push?.remote_after_sha !== remediation.candidate_head_sha)) errors.push({ path: `${path}.push`, message: "remote-confirmed requires confirmed remote_after_sha equal to candidate_head_sha" });
788
+ }
789
+
790
+ function validatePostPrOwner(errors, owner, path, route, lane) {
791
+ if (!isRecord(owner)) { errors.push({ path, message: "must be an object" }); return; }
792
+ allowedKeys(errors, owner, new Set(["kind", "slice_id", "stack", "path_b64url", "method"]), path);
793
+ requiredEnum(errors, owner, "kind", new Set(["slice", "integration"]), `${path}.kind`);
794
+ requiredString(errors, owner, "method", `${path}.method`);
795
+ if (owner.kind === "slice") {
796
+ requiredString(errors, owner, "slice_id", `${path}.slice_id`);
797
+ requiredEnum(errors, owner, "stack", new Set(["backend", "frontend"]), `${path}.stack`);
798
+ if (lane !== "slice" || route !== `${owner.stack}-builder`) errors.push({ path, message: "slice owner requires matching slice lane and builder route" });
799
+ } else if (lane !== "test" || route !== "test-verifier") errors.push({ path, message: "integration owner requires test lane and test-verifier route" });
800
+ }
801
+
802
+ function validatePostPrDispatch(errors, dispatch, path, remediation) {
803
+ if (!isRecord(dispatch)) { errors.push({ path, message: "must be an object" }); return; }
804
+ allowedKeys(errors, dispatch, new Set(["id", "status", "role", "subject", "started_at", "returned_at"]), path);
805
+ requiredString(errors, dispatch, "id", `${path}.id`);
806
+ requiredEnum(errors, dispatch, "status", new Set(["planned", "running", "returned"]), `${path}.status`);
807
+ requiredString(errors, dispatch, "role", `${path}.role`);
808
+ requiredString(errors, dispatch, "subject", `${path}.subject`);
809
+ optionalNullableString(errors, dispatch, "started_at", `${path}.started_at`);
810
+ optionalNullableString(errors, dispatch, "returned_at", `${path}.returned_at`);
811
+ if (stringValue(remediation.route) && dispatch.role !== remediation.route) errors.push({ path: `${path}.role`, message: "must match remediation route" });
812
+ }
813
+
814
+ function validatePostPrChanges(errors, changes, path) {
815
+ if (!isRecord(changes)) { errors.push({ path, message: "must be an object" }); return; }
816
+ allowedKeys(errors, changes, new Set(["paths", "entries", "tree_hash"]), path);
817
+ validateStringArray(errors, changes.paths, `${path}.paths`, { required: true });
818
+ if (changes.entries !== undefined) {
819
+ if (!Array.isArray(changes.entries)) errors.push({ path: `${path}.entries`, message: "must be an array" });
820
+ else changes.entries.forEach((entry, index) => validatePostPrChangeEntry(errors, entry, `${path}.entries[${index}]`));
821
+ }
822
+ optionalNullableHash(errors, changes, "tree_hash", `${path}.tree_hash`);
823
+ }
824
+
825
+ function validatePostPrChangeEntry(errors, entry, path) {
826
+ if (!isRecord(entry)) { errors.push({ path, message: "must be an object" }); return; }
827
+ allowedKeys(errors, entry, new Set(["source", "status", "index_status", "worktree_status", "path", "previous_path", "old_mode", "new_mode"]), path);
828
+ requiredEnum(errors, entry, "source", new Set(["worktree", "commit"]), `${path}.source`);
829
+ requiredEnum(errors, entry, "status", new Set(["modified", "added", "untracked", "deleted", "renamed", "copied"]), `${path}.status`);
830
+ requiredString(errors, entry, "path", `${path}.path`);
831
+ for (const key of ["index_status", "worktree_status", "previous_path", "old_mode", "new_mode"]) if (entry[key] !== undefined && entry[key] !== null && typeof entry[key] !== "string") errors.push({ path: `${path}.${key}`, message: "must be a string or null" });
832
+ }
833
+
834
+ function validatePostPrRevalidation(errors, value, path) {
835
+ if (!isRecord(value)) { errors.push({ path, message: "must be an object" }); return; }
836
+ const refs = ["canonical_evidence", "validator_review", "security_review"];
837
+ allowedKeys(errors, value, new Set(refs.flatMap((name) => [`${name}_ref`, `${name}_hash`]).concat(["canonical_verdict", "validator_verdict", "security_verdict", "jobs"])), path);
838
+ for (const name of refs) {
839
+ optionalNullableString(errors, value, `${name}_ref`, `${path}.${name}_ref`);
840
+ optionalNullableHash(errors, value, `${name}_hash`, `${path}.${name}_hash`);
841
+ if ((value[`${name}_ref`] === null) !== (value[`${name}_hash`] === null)) errors.push({ path, message: `${name} ref/hash must be set together` });
842
+ }
843
+ optionalNullableEnum(errors, value, "canonical_verdict", new Set(["pass", "fail"]), `${path}.canonical_verdict`);
844
+ optionalNullableEnum(errors, value, "validator_verdict", VALIDATOR_VERDICTS, `${path}.validator_verdict`);
845
+ optionalNullableEnum(errors, value, "security_verdict", SECURITY_VERDICTS, `${path}.security_verdict`);
846
+ if (value.jobs !== undefined) validatePostPrJobs(errors, value.jobs, `${path}.jobs`);
847
+ }
848
+
849
+ function validatePostPrJobs(errors, jobs, path) {
850
+ if (!isRecord(jobs)) { errors.push({ path, message: "must be an object" }); return; }
851
+ allowedKeys(errors, jobs, new Set(["canonical", "validator", "security"]), path);
852
+ for (const activity of ["canonical", "validator", "security"]) if (jobs[activity] !== undefined) validatePostPrJob(errors, jobs[activity], `${path}.${activity}`, activity);
853
+ }
854
+
855
+ function validatePostPrJob(errors, job, path, activity) {
856
+ if (!isRecord(job)) { errors.push({ path, message: "must be an object" }); return; }
857
+ allowedKeys(errors, job, new Set(["dispatch_id", "status", "action_token", "steering_generation", "started_at", "returned_at", "result_ref", "result_hash", "verdict", "transient_error_count", "next_retry_at", "last_error"]), path);
858
+ requiredString(errors, job, "dispatch_id", `${path}.dispatch_id`);
859
+ requiredEnum(errors, job, "status", new Set(["planned", "running", "retry-wait", "bound"]), `${path}.status`);
860
+ for (const key of ["action_token", "started_at", "returned_at", "result_ref", "result_hash", "verdict", "next_retry_at", "last_error"]) if (job[key] !== null && job[key] !== undefined && typeof job[key] !== "string") errors.push({ path: `${path}.${key}`, message: "must be a string or null" });
861
+ if (job.steering_generation !== null && job.steering_generation !== undefined && (!Number.isInteger(job.steering_generation) || job.steering_generation < 0)) errors.push({ path: `${path}.steering_generation`, message: "must be a non-negative integer or null" });
862
+ boundedInteger(errors, job, "transient_error_count", 0, Number.MAX_SAFE_INTEGER, `${path}.transient_error_count`);
863
+ if (job.status === "running" && (!stringValue(job.action_token) || !stringValue(job.started_at))) errors.push({ path, message: "running job requires action token and start time" });
864
+ if (job.status === "bound" && (!stringValue(job.returned_at) || !stringValue(job.result_ref) || !stringValue(job.result_hash) || !stringValue(job.verdict))) errors.push({ path, message: "bound job requires return/ref/hash/verdict" });
865
+ const vocabulary = activity === "canonical" ? new Set(["pass", "red"]) : activity === "validator" ? VALIDATOR_VERDICTS : SECURITY_VERDICTS;
866
+ if (stringValue(job.verdict) && !vocabulary.has(job.verdict)) errors.push({ path: `${path}.verdict`, message: "is outside the activity verdict vocabulary" });
867
+ }
868
+
869
+ function validatePostPrPush(errors, push, path) {
870
+ if (!isRecord(push)) { errors.push({ path, message: "must be an object" }); return; }
871
+ allowedKeys(errors, push, new Set(["status", "remote_before_sha", "local_head_sha", "remote_after_sha", "consecutive_transient_errors", "next_retry_at", "pushed_at", "last_error"]), path);
872
+ requiredEnum(errors, push, "status", new Set(["not-ready", "pending", "confirmed"]), `${path}.status`);
873
+ for (const key of ["remote_before_sha", "local_head_sha", "remote_after_sha"]) optionalNullableFullGitSha(errors, push, key, `${path}.${key}`);
874
+ boundedInteger(errors, push, "consecutive_transient_errors", 0, Number.MAX_SAFE_INTEGER, `${path}.consecutive_transient_errors`);
875
+ optionalNullableString(errors, push, "next_retry_at", `${path}.next_retry_at`);
876
+ optionalNullableString(errors, push, "pushed_at", `${path}.pushed_at`);
877
+ if (push.last_error !== undefined && push.last_error !== null) {
878
+ const error = push.last_error;
879
+ if (!isRecord(error)) errors.push({ path: `${path}.last_error`, message: "must be an object or null" });
880
+ else {
881
+ allowedKeys(errors, error, new Set(["operation", "observed_at", "error_class", "exit_code", "classification", "error_count", "error_limit", "expected_remote_sha", "candidate_head_sha", "next_retry_at"]), `${path}.last_error`);
882
+ requiredEnum(errors, error, "operation", new Set(["remote-head", "fast-forward-push", "remote-confirmation"]), `${path}.last_error.operation`);
883
+ requiredEnum(errors, error, "classification", new Set(["transient", "permanent", "exhausted"]), `${path}.last_error.classification`);
884
+ requiredString(errors, error, "observed_at", `${path}.last_error.observed_at`);
885
+ }
886
+ }
887
+ }
888
+
889
+ function validatePostPrValidated(errors, remediation, path) {
890
+ const value = remediation.revalidation;
891
+ if (value?.canonical_verdict !== "pass") errors.push({ path: `${path}.revalidation.canonical_verdict`, message: "must be pass when validated" });
892
+ if (!PASSING_VALIDATOR_VERDICTS.has(value?.validator_verdict)) errors.push({ path: `${path}.revalidation.validator_verdict`, message: "must be GO or GO-WITH-NITS when validated" });
893
+ if (!PASSING_SECURITY_VERDICTS.has(value?.security_verdict)) errors.push({ path: `${path}.revalidation.security_verdict`, message: "must be PASS when validated" });
894
+ for (const key of ["canonical_evidence_ref", "canonical_evidence_hash", "validator_review_ref", "validator_review_hash", "security_review_ref", "security_review_hash"]) if (!stringValue(value?.[key])) errors.push({ path: `${path}.revalidation.${key}`, message: "is required when validated" });
895
+ if (!stringValue(remediation.candidate_head_sha)) errors.push({ path: `${path}.candidate_head_sha`, message: "is required when validated" });
896
+ }
897
+
898
+ function validatePostPrTerminalConsistency(errors, run, postPr, path) {
899
+ const expected = postPr.phase === "succeeded" ? "completed" : postPr.phase === "blocked" ? "blocked" : postPr.phase === "needs-human" ? "needs-human" : null;
900
+ if (!expected) return;
901
+ if (run.status !== expected) errors.push({ path: "run.status", message: `must be ${expected} when post-PR phase is ${postPr.phase}` });
902
+ const reason = run.terminal_result?.reason;
903
+ if (!POST_PR_TERMINAL_REASONS[expected]?.includes(reason)) errors.push({ path: "run.terminal_result.reason", message: `must be a closed post-PR ${expected} reason` });
904
+ }
905
+
906
+ function validatePostPrRefHashArray(errors, items, path) {
907
+ if (!Array.isArray(items)) { errors.push({ path, message: "must be an array" }); return; }
908
+ const refs = new Set();
909
+ for (const [index, item] of items.entries()) {
910
+ validatePostPrRefHash(errors, item, `${path}[${index}]`);
911
+ if (stringValue(item?.ref) && refs.has(item.ref)) errors.push({ path: `${path}[${index}].ref`, message: "must be unique" });
912
+ if (stringValue(item?.ref)) refs.add(item.ref);
913
+ }
914
+ }
915
+
916
+ function validatePostPrRefHash(errors, value, path, { optional = false } = {}) {
917
+ if (value === undefined || value === null) { if (!optional) errors.push({ path, message: "must be an object" }); return; }
918
+ if (!isRecord(value)) { errors.push({ path, message: "must be an object" }); return; }
919
+ allowedKeys(errors, value, new Set(["ref", "hash"]), path);
920
+ requiredString(errors, value, "ref", `${path}.ref`);
921
+ requiredHash(errors, value, "hash", `${path}.hash`);
922
+ }
923
+
924
+ function validateSteering(errors, steering, path) {
925
+ if (steering === undefined || steering === null) return;
926
+ if (!isRecord(steering)) {
927
+ errors.push({ path, message: "must be an object" });
928
+ return;
929
+ }
930
+ requiredInteger(errors, steering, "schema_version", `${path}.schema_version`);
931
+ if (Number.isInteger(steering.schema_version) && steering.schema_version !== 1) errors.push({ path: `${path}.schema_version`, message: "must equal 1" });
932
+ optionalInteger(errors, steering, "generation", `${path}.generation`);
933
+ if (Number.isInteger(steering.generation) && steering.generation < 0) errors.push({ path: `${path}.generation`, message: "must be non-negative" });
934
+ if (steering.pending !== undefined && steering.pending !== null) validateSteeringEntry(errors, steering.pending, `${path}.pending`, { pending: true });
935
+ if (steering.pending !== undefined && steering.pending !== null && !isRecord(steering.pending)) errors.push({ path: `${path}.pending`, message: "must be an object or null" });
936
+ if (steering.uncheckpointed !== undefined && steering.uncheckpointed !== null) validateSteeringEntry(errors, steering.uncheckpointed, `${path}.uncheckpointed`, { consumed: true });
937
+ if (steering.uncheckpointed !== undefined && steering.uncheckpointed !== null && !isRecord(steering.uncheckpointed)) errors.push({ path: `${path}.uncheckpointed`, message: "must be an object or null" });
938
+ validateSteeringBoundary(errors, steering.boundary, `${path}.boundary`, { fence: false });
939
+ validateSteeringAction(errors, steering.action_claim, `${path}.action_claim`, { claim: true });
940
+ validateSteeringAction(errors, steering.last_action, `${path}.last_action`, { resolved: true });
941
+ validateSteeringBoundary(errors, steering.pr_fence, `${path}.pr_fence`, { fence: true });
942
+ if (steering.pending !== undefined && steering.pending !== null && steering.uncheckpointed !== undefined && steering.uncheckpointed !== null) {
943
+ errors.push({ path, message: "cannot have both pending and uncheckpointed steering" });
944
+ }
945
+ if (steering.boundary !== undefined && steering.boundary !== null && (steering.pending !== undefined && steering.pending !== null || steering.uncheckpointed !== undefined && steering.uncheckpointed !== null)) {
946
+ errors.push({ path, message: "boundary cannot coexist with pending or uncheckpointed steering" });
947
+ }
948
+ if (steering.action_claim !== undefined && steering.action_claim !== null && (steering.pending !== undefined && steering.pending !== null || steering.uncheckpointed !== undefined && steering.uncheckpointed !== null || steering.boundary !== undefined && steering.boundary !== null)) {
949
+ errors.push({ path, message: "action claim cannot coexist with pending, uncheckpointed, or boundary steering state" });
950
+ }
951
+ if (steering.pr_fence !== undefined && steering.pr_fence !== null && (steering.pending !== undefined && steering.pending !== null || steering.uncheckpointed !== undefined && steering.uncheckpointed !== null || steering.boundary !== undefined && steering.boundary !== null || steering.action_claim !== undefined && steering.action_claim !== null)) {
952
+ errors.push({ path, message: "pre-PR fence cannot coexist with pending, uncheckpointed, boundary, or action claim steering state" });
953
+ }
954
+ if (steering.history === undefined || steering.history === null) return;
955
+ if (!Array.isArray(steering.history)) {
956
+ errors.push({ path: `${path}.history`, message: "must be an array" });
957
+ return;
958
+ }
959
+ for (const [index, entry] of steering.history.entries()) validateSteeringEntry(errors, entry, `${path}.history[${index}]`, { history: true });
960
+ }
961
+
962
+ function validateSteeringEntry(errors, entry, path, options = {}) {
963
+ if (!isRecord(entry)) {
964
+ errors.push({ path, message: "must be an object" });
965
+ return;
966
+ }
967
+ if (options.history) requiredEnum(errors, entry, "event", new Set(["queued", "consumed", "acknowledged"]), `${path}.event`);
968
+ requiredString(errors, entry, "id", `${path}.id`);
969
+ requiredString(errors, entry, "ref", `${path}.ref`);
970
+ if (stringValue(entry.ref) && (options.consumed || entry.event === "consumed" || entry.event === "acknowledged") && !/^steering\/consumed-[^/]+\.json$/u.test(entry.ref)) errors.push({ path: `${path}.ref`, message: "must name a consumed steering file" });
971
+ requiredHash(errors, entry, "hash", `${path}.hash`);
972
+ requiredInteger(errors, entry, "message_chars", `${path}.message_chars`);
973
+ requiredString(errors, entry, "created_at", `${path}.created_at`);
974
+ if (entry.event === "consumed") {
975
+ requiredString(errors, entry, "source_ref", `${path}.source_ref`);
976
+ requiredString(errors, entry, "consumed_at", `${path}.consumed_at`);
977
+ }
978
+ if (options.consumed || entry.event === "acknowledged") requiredString(errors, entry, "consumed_at", `${path}.consumed_at`);
979
+ if (entry.event === "acknowledged") {
980
+ requiredString(errors, entry, "acknowledged_at", `${path}.acknowledged_at`);
981
+ requiredEnum(errors, entry, "outcome", new Set(["applied-prospectively"]), `${path}.outcome`);
982
+ }
983
+ }
984
+
985
+ function validateSteeringBoundary(errors, boundary, path, options = {}) {
986
+ if (boundary === undefined || boundary === null) return;
987
+ if (!isRecord(boundary)) {
988
+ errors.push({ path, message: "must be an object or null" });
989
+ return;
990
+ }
991
+ if (!options.fence) requiredEnum(errors, boundary, "kind", new Set(["gate", "dispatch", "remediation", "terminal", "post-pr-observe", "post-pr-push"]), `${path}.kind`);
992
+ requiredString(errors, boundary, "token", `${path}.token`);
993
+ if (stringValue(boundary.token) && !/^[A-Za-z0-9_-]{8,128}$/u.test(boundary.token)) errors.push({ path: `${path}.token`, message: "must use 8-128 safe characters" });
994
+ requiredInteger(errors, boundary, "generation", `${path}.generation`);
995
+ if (Number.isInteger(boundary.generation) && boundary.generation < 0) errors.push({ path: `${path}.generation`, message: "must be non-negative" });
996
+ requiredHash(errors, boundary, "state_hash", `${path}.state_hash`);
997
+ requiredString(errors, boundary, "created_at", `${path}.created_at`);
998
+ }
999
+
1000
+ function validateSteeringAction(errors, action, path, options = {}) {
1001
+ if (action === undefined || action === null) return;
1002
+ if (!isRecord(action)) {
1003
+ errors.push({ path, message: "must be an object or null" });
1004
+ return;
1005
+ }
1006
+ requiredEnum(errors, action, "kind", new Set(["dispatch", "remediation", "terminal", "post-pr-observe", "post-pr-push"]), `${path}.kind`);
1007
+ requiredString(errors, action, "token", `${path}.token`);
1008
+ if (stringValue(action.token) && !/^[A-Za-z0-9_-]{8,128}$/u.test(action.token)) errors.push({ path: `${path}.token`, message: "must use 8-128 safe characters" });
1009
+ requiredInteger(errors, action, "generation", `${path}.generation`);
1010
+ if (Number.isInteger(action.generation) && action.generation < 0) errors.push({ path: `${path}.generation`, message: "must be non-negative" });
1011
+ requiredString(errors, action, "claimed_at", `${path}.claimed_at`);
1012
+ if (options.resolved) {
1013
+ requiredEnum(errors, action, "outcome", new Set(["started", "aborted", "closed"]), `${path}.outcome`);
1014
+ requiredString(errors, action, "resolved_at", `${path}.resolved_at`);
1015
+ }
1016
+ }
1017
+
1018
+ function validateContinuationParent(errors, parent, path) {
1019
+ if (!isRecord(parent)) {
1020
+ errors.push({ path, message: "must be an object" });
1021
+ return;
1022
+ }
1023
+ requiredString(errors, parent, "run_id", `${path}.run_id`);
1024
+ requiredEnum(errors, parent, "status", BLOCKED_CONTINUATION_PARENT_STATUSES, `${path}.status`);
1025
+ requiredString(errors, parent, "run_ref", `${path}.run_ref`);
1026
+ requiredHash(errors, parent, "run_hash", `${path}.run_hash`);
1027
+ requiredString(errors, parent, "branch", `${path}.branch`);
1028
+ requiredString(errors, parent, "commit", `${path}.commit`);
1029
+ requiredString(errors, parent, "worktree", `${path}.worktree`);
1030
+ }
1031
+
1032
+ function validateContinuationReview(errors, review, path) {
1033
+ if (!isRecord(review)) {
1034
+ errors.push({ path, message: "must be an object" });
1035
+ return;
1036
+ }
1037
+ requiredString(errors, review, "ref", `${path}.ref`);
1038
+ requiredString(errors, review, "kind", `${path}.kind`);
1039
+ optionalString(errors, review, "source", `${path}.source`);
1040
+ requiredHash(errors, review, "hash", `${path}.hash`);
1041
+ requiredString(errors, review, "subject", `${path}.subject`);
1042
+ optionalString(errors, review, "verdict", `${path}.verdict`);
1043
+ optionalString(errors, review, "summary", `${path}.summary`);
1044
+ validateStringArray(errors, review.required_fixes, `${path}.required_fixes`, { required: false });
1045
+ if (!stringValue(review.summary) && !hasNonEmptyStringItem(review.required_fixes)) {
1046
+ errors.push({ path, message: "requires summary or required_fixes" });
1047
+ }
1048
+ }
1049
+
1050
+ function validateContinuationTarget(errors, run, target, path) {
1051
+ if (!isRecord(target)) {
1052
+ errors.push({ path, message: "must be an object" });
1053
+ return;
1054
+ }
1055
+ requiredString(errors, target, "run_id", `${path}.run_id`);
1056
+ requiredString(errors, target, "branch", `${path}.branch`);
1057
+ requiredString(errors, target, "worktree", `${path}.worktree`);
1058
+ requiredString(errors, target, "base_ref", `${path}.base_ref`);
1059
+ requiredString(errors, target, "base_commit", `${path}.base_commit`);
1060
+ if (stringValue(target.run_id) && stringValue(run.run_id) && target.run_id !== run.run_id) errors.push({ path: `${path}.run_id`, message: "must match run.run_id" });
1061
+ if (stringValue(target.branch) && stringValue(run.branch) && target.branch !== run.branch) errors.push({ path: `${path}.branch`, message: "must match run.branch" });
1062
+ if (stringValue(target.worktree) && stringValue(run.worktree) && target.worktree !== run.worktree) errors.push({ path: `${path}.worktree`, message: "must match run.worktree" });
1063
+ }
1064
+
1065
+ function validateContinuationRefHashArray(errors, items, path) {
1066
+ if (!Array.isArray(items)) {
1067
+ errors.push({ path, message: "must be an array" });
1068
+ return;
1069
+ }
1070
+ for (const [index, item] of items.entries()) {
1071
+ const itemPath = `${path}[${index}]`;
1072
+ if (!isRecord(item)) {
1073
+ errors.push({ path: itemPath, message: "must be an object" });
1074
+ continue;
1075
+ }
1076
+ requiredString(errors, item, "kind", `${itemPath}.kind`);
1077
+ requiredString(errors, item, "ref", `${itemPath}.ref`);
1078
+ requiredHash(errors, item, "hash", `${itemPath}.hash`);
1079
+ }
1080
+ }
1081
+
1082
+ function validateContinuationSelectedReview(errors, continuation, path) {
1083
+ const review = continuation.review;
1084
+ if (!isRecord(review) || !Array.isArray(continuation.parent_reviews)) return;
1085
+ if (!stringValue(review.ref) || typeof review.hash !== "string" || !HASH_PATTERN.test(review.hash)) return;
1086
+ const match = continuation.parent_reviews.find((item) => isRecord(item) && item.ref === review.ref);
1087
+ if (!match) {
1088
+ errors.push({ path: `${path}.parent_reviews`, message: "must include selected review ref" });
1089
+ return;
1090
+ }
1091
+ if (match.hash !== review.hash) {
1092
+ errors.push({ path: `${path}.parent_reviews`, message: "selected review hash must match review.hash" });
1093
+ }
1094
+ }
1095
+
1096
+ function validateRedactedEnv(errors, value, path) {
1097
+ if (Array.isArray(value)) {
1098
+ for (const [index, item] of value.entries()) validateRedactedEnv(errors, item, `${path}[${index}]`);
1099
+ return;
1100
+ }
1101
+ if (typeof value === "string") {
1102
+ if (value !== REDACTED_ENV_VALUE && isSensitiveEnvValue(value)) errors.push({ path, message: "must be redacted in debug snapshot" });
1103
+ return;
1104
+ }
1105
+ if (!isRecord(value)) return;
1106
+ for (const [key, item] of Object.entries(value)) {
1107
+ const itemPath = `${path}.${key}`;
1108
+ if (isSensitiveEnvKey(key) && item !== REDACTED_ENV_VALUE) errors.push({ path: itemPath, message: "is not allowed in debug snapshot" });
1109
+ validateRedactedEnv(errors, item, itemPath);
1110
+ }
1111
+ }
1112
+
1113
+ function validateGate(errors, gate, path, gateName) {
1114
+ if (!isRecord(gate)) {
1115
+ errors.push({ path, message: "must be an object" });
1116
+ return;
1117
+ }
1118
+ requiredEnum(errors, gate, "status", GATE_STATUSES, `${path}.status`);
1119
+ optionalString(errors, gate, "artifact", `${path}.artifact`);
1120
+ optionalString(errors, gate, "question_ref", `${path}.question_ref`);
1121
+ optionalString(errors, gate, "answer_ref", `${path}.answer_ref`);
1122
+ optionalString(errors, gate, "answered_at", `${path}.answered_at`);
1123
+ optionalString(errors, gate, "answer", `${path}.answer`);
1124
+ optionalString(errors, gate, "decision_note", `${path}.decision_note`);
1125
+ optionalEnum(errors, gate, "approval_source", APPROVAL_SOURCES, `${path}.approval_source`);
1126
+ validatePendingSnapshot(errors, gate.pending_snapshot, `${path}.pending_snapshot`);
1127
+ validateGateHandoffReceipt(errors, gate.handoff_receipt, `${path}.handoff_receipt`, gateName);
1128
+ }
1129
+
1130
+ function validateGateHandoffReceipt(errors, receipt, path, gateName) {
1131
+ if (receipt === undefined || receipt === null) return;
1132
+ if (!isRecord(receipt)) {
1133
+ errors.push({ path, message: "must be an object" });
1134
+ return;
1135
+ }
1136
+ requiredInteger(errors, receipt, "schema_version", `${path}.schema_version`);
1137
+ if (Number.isInteger(receipt.schema_version) && receipt.schema_version !== 1) errors.push({ path: `${path}.schema_version`, message: "must equal 1" });
1138
+ requiredString(errors, receipt, "kind", `${path}.kind`);
1139
+ if (stringValue(receipt.kind) && receipt.kind !== HANDOFF_RECEIPT_KIND) errors.push({ path: `${path}.kind`, message: `must equal ${HANDOFF_RECEIPT_KIND}` });
1140
+ requiredString(errors, receipt, "gate", `${path}.gate`);
1141
+ if (stringValue(receipt.gate) && receipt.gate !== gateName) errors.push({ path: `${path}.gate`, message: "must match its gate key" });
1142
+ requiredHash(errors, receipt, "approval_fingerprint", `${path}.approval_fingerprint`);
1143
+ requiredHash(errors, receipt, "pending_snapshot_hash", `${path}.pending_snapshot_hash`);
1144
+ requiredHash(errors, receipt, "answer_hash", `${path}.answer_hash`);
1145
+ requiredInteger(errors, receipt, "steering_generation", `${path}.steering_generation`);
1146
+ if (Number.isInteger(receipt.steering_generation) && receipt.steering_generation < 0) errors.push({ path: `${path}.steering_generation`, message: "must be non-negative" });
1147
+ requiredString(errors, receipt, "accepted_at", `${path}.accepted_at`);
1148
+ }
1149
+
1150
+ function validatePendingSnapshot(errors, pendingSnapshot, path) {
1151
+ if (pendingSnapshot === undefined || pendingSnapshot === null) return;
1152
+ if (!isRecord(pendingSnapshot)) {
1153
+ errors.push({ path, message: "must be an object" });
1154
+ return;
1155
+ }
1156
+ requiredString(errors, pendingSnapshot, "question_ref", `${path}.question_ref`);
1157
+ requiredHash(errors, pendingSnapshot, "question_hash", `${path}.question_hash`);
1158
+ requiredString(errors, pendingSnapshot, "artifact_ref", `${path}.artifact_ref`);
1159
+ requiredHash(errors, pendingSnapshot, "artifact_hash", `${path}.artifact_hash`);
1160
+ optionalString(errors, pendingSnapshot, "answer_ref", `${path}.answer_ref`);
1161
+ optionalHash(errors, pendingSnapshot, "answer_hash", `${path}.answer_hash`);
1162
+ requiredString(errors, pendingSnapshot, "created_at", `${path}.created_at`);
1163
+ }
1164
+
1165
+ function isPendingGate(gate) {
1166
+ return isRecord(gate) && gate.status === "pending";
1167
+ }
1168
+
1169
+ function validateRunSlices(errors, slices, path) {
1170
+ if (slices === undefined || slices === null) return;
1171
+ if (!Array.isArray(slices)) {
1172
+ errors.push({ path, message: "must be an array" });
1173
+ return;
1174
+ }
1175
+ const ids = validateSliceIDs(errors, slices, path);
1176
+ for (const [index, slice] of slices.entries()) validateRunSlice(errors, slice, `${path}[${index}]`, ids);
1177
+ }
1178
+
1179
+ function validateRunSlice(errors, slice, path, ids) {
1180
+ if (!isRecord(slice)) {
1181
+ errors.push({ path, message: "must be an object" });
1182
+ return;
1183
+ }
1184
+ requiredTerminalSafeString(errors, slice, "id", `${path}.id`);
1185
+ optionalString(errors, slice, "stack", `${path}.stack`);
1186
+ validateStringArray(errors, slice.depends_on, `${path}.depends_on`, { required: false, values: ids });
1187
+ optionalEnum(errors, slice, "status", SLICE_STATUSES, `${path}.status`);
1188
+ optionalString(errors, slice, "branch", `${path}.branch`);
1189
+ optionalString(errors, slice, "worktree", `${path}.worktree`);
1190
+ optionalInteger(errors, slice, "attempts", `${path}.attempts`);
1191
+ optionalString(errors, slice, "evidence_ref", `${path}.evidence_ref`);
1192
+ optionalString(errors, slice, "review_ref", `${path}.review_ref`);
1193
+ optionalString(errors, slice, "merge_commit", `${path}.merge_commit`);
1194
+ optionalString(errors, slice, "blocked_reason", `${path}.blocked_reason`);
1195
+ }
1196
+
1197
+ function validatePlannedSlices(errors, slices, path, { enforceDependencyDepth }) {
1198
+ const ids = validateSliceIDs(errors, slices, path);
1199
+ for (const [index, slice] of slices.entries()) {
1200
+ if (!isRecord(slice)) {
1201
+ errors.push({ path: `${path}[${index}]`, message: "must be an object" });
1202
+ continue;
1203
+ }
1204
+ requiredTerminalSafeString(errors, slice, "id", `${path}[${index}].id`);
1205
+ requiredString(errors, slice, "stack", `${path}[${index}].stack`);
1206
+ validateStringArray(errors, slice.paths, `${path}[${index}].paths`, { required: true, nonEmpty: true });
1207
+ validateStringArray(errors, slice.depends_on, `${path}[${index}].depends_on`, { required: true, values: ids });
1208
+ validateStringArray(errors, slice.acceptance, `${path}[${index}].acceptance`, { required: true, nonEmpty: true });
1209
+ validateStringArray(errors, slice.test_plan, `${path}[${index}].test_plan`, { required: true, nonEmpty: true });
1210
+ }
1211
+ const acyclic = validateAcyclic(errors, slices, ids, path);
1212
+ if (enforceDependencyDepth && errors.length === 0 && acyclic) validateDependencyDepth(errors, slices, path);
1213
+ }
1214
+
1215
+ function validateSliceIDs(errors, slices, path) {
1216
+ const ids = new Set();
1217
+ for (const [index, slice] of slices.entries()) {
1218
+ if (!isRecord(slice) || typeof slice.id !== "string" || !slice.id.trim()) continue;
1219
+ if (ids.has(slice.id)) errors.push({ path: `${path}[${index}].id`, message: `duplicate id '${safeValidationIdentifier(slice.id)}'` });
1220
+ ids.add(slice.id);
1221
+ }
1222
+ return ids;
1223
+ }
1224
+
1225
+ function validateAcyclic(errors, slices, ids, path) {
1226
+ const graph = new Map();
1227
+ for (const slice of slices) if (isRecord(slice) && typeof slice.id === "string" && ids.has(slice.id)) graph.set(slice.id, Array.isArray(slice.depends_on) ? slice.depends_on.filter((id) => ids.has(id)) : []);
1228
+ const visiting = new Set();
1229
+ const visited = new Set();
1230
+ let acyclic = true;
1231
+ const visit = (id, chain) => {
1232
+ if (visited.has(id)) return;
1233
+ if (visiting.has(id)) {
1234
+ errors.push({ path, message: `dependency cycle: ${[...chain, id].join(" -> ")}` });
1235
+ acyclic = false;
1236
+ return;
1237
+ }
1238
+ visiting.add(id);
1239
+ for (const dep of graph.get(id) || []) visit(dep, [...chain, id]);
1240
+ visiting.delete(id);
1241
+ visited.add(id);
1242
+ };
1243
+ for (const id of graph.keys()) visit(id, []);
1244
+ return acyclic;
1245
+ }
1246
+
1247
+ function validateDependencyDepth(errors, slices, path) {
1248
+ const byId = new Map(slices.map((slice, index) => [slice.id, { slice, index }]));
1249
+ const memo = new Map();
1250
+ const longestPath = (id) => {
1251
+ if (memo.has(id)) return memo.get(id);
1252
+ const dependencies = byId.get(id).slice.depends_on;
1253
+ let result = { depth: 1, ids: [id] };
1254
+ for (const dependency of dependencies) {
1255
+ const parent = longestPath(dependency);
1256
+ if (parent.depth + 1 > result.depth) result = { depth: parent.depth + 1, ids: [...parent.ids, id] };
1257
+ }
1258
+ memo.set(id, result);
1259
+ return result;
1260
+ };
1261
+
1262
+ for (const [id, { index }] of byId) {
1263
+ const result = longestPath(id);
1264
+ if (result.depth > MAX_SLICE_DEPENDENCY_WAVES) {
1265
+ errors.push({
1266
+ path: `${path}[${index}].depends_on`,
1267
+ message: `dependency depth ${result.depth} exceeds maximum ${MAX_SLICE_DEPENDENCY_WAVES} waves: ${result.ids.join(" -> ")}`,
1268
+ });
1269
+ }
1270
+ }
1271
+ }
1272
+
1273
+ function validateSteps(errors, steps, path) {
1274
+ if (steps === undefined || steps === null) return;
1275
+ if (!Array.isArray(steps)) {
1276
+ errors.push({ path, message: "must be an array" });
1277
+ return;
1278
+ }
1279
+ for (const [index, step] of steps.entries()) {
1280
+ if (!isRecord(step)) {
1281
+ errors.push({ path: `${path}[${index}]`, message: "must be an object" });
1282
+ continue;
1283
+ }
1284
+ requiredTerminalSafeString(errors, step, "agent", `${path}[${index}].agent`);
1285
+ requiredEnum(errors, step, "status", STEP_STATUSES, `${path}[${index}].status`);
1286
+ optionalInteger(errors, step, "attempts", `${path}[${index}].attempts`);
1287
+ optionalString(errors, step, "artifact_ref", `${path}[${index}].artifact_ref`);
1288
+ optionalString(errors, step, "review_ref", `${path}[${index}].review_ref`);
1289
+ optionalString(errors, step, "evidence_ref", `${path}[${index}].evidence_ref`);
1290
+ validateStepAcceptance(errors, step.acceptance, `${path}[${index}].acceptance`);
1291
+ validateStepInheritedAcceptance(errors, step.inherited_acceptance, `${path}[${index}].inherited_acceptance`);
1292
+ }
1293
+ }
1294
+
1295
+ function validateStepAcceptance(errors, acceptance, path) {
1296
+ if (acceptance === undefined || acceptance === null) return;
1297
+ if (!isRecord(acceptance)) {
1298
+ errors.push({ path, message: "must be an object" });
1299
+ return;
1300
+ }
1301
+ requiredString(errors, acceptance, "artifact_ref", `${path}.artifact_ref`);
1302
+ requiredHash(errors, acceptance, "artifact_hash", `${path}.artifact_hash`);
1303
+ optionalString(errors, acceptance, "review_ref", `${path}.review_ref`);
1304
+ if (acceptance.review_ref !== undefined && acceptance.review_ref !== null) requiredHash(errors, acceptance, "review_hash", `${path}.review_hash`);
1305
+ }
1306
+
1307
+ function validateStepInheritedAcceptance(errors, inherited, path) {
1308
+ if (inherited === undefined || inherited === null) return;
1309
+ if (!isRecord(inherited)) {
1310
+ errors.push({ path, message: "must be an object" });
1311
+ return;
1312
+ }
1313
+ requiredString(errors, inherited, "from_run_id", `${path}.from_run_id`);
1314
+ requiredString(errors, inherited, "parent_spec_review_ref", `${path}.parent_spec_review_ref`);
1315
+ requiredHash(errors, inherited, "artifact_hash", `${path}.artifact_hash`);
1316
+ requiredHash(errors, inherited, "review_hash", `${path}.review_hash`);
1317
+ }
1318
+
1319
+ function validateVerdict(errors, value, path, allowed) {
1320
+ if (value === undefined || value === null) return;
1321
+ if (!isRecord(value)) {
1322
+ errors.push({ path, message: "must be an object" });
1323
+ return;
1324
+ }
1325
+ optionalEnum(errors, value, "verdict", allowed, `${path}.verdict`);
1326
+ optionalString(errors, value, "report", `${path}.report`);
1327
+ optionalString(errors, value, "review_ref", `${path}.review_ref`);
1328
+ optionalInteger(errors, value, "loops", `${path}.loops`);
1329
+ }
1330
+
1331
+ function validateTerminalResult(errors, run, path) {
1332
+ const terminal = TERMINAL_STATUSES.has(run.status);
1333
+ if (!terminal && (run.terminal_result === undefined || run.terminal_result === null)) return;
1334
+ if (terminal && !isRecord(run.terminal_result)) {
1335
+ errors.push({ path, message: "must be present when run.status is terminal" });
1336
+ return;
1337
+ }
1338
+ if (!isRecord(run.terminal_result)) {
1339
+ errors.push({ path, message: "must be an object or null" });
1340
+ return;
1341
+ }
1342
+ requiredEnum(errors, run.terminal_result, "status", TERMINAL_STATUSES, `${path}.status`);
1343
+ requiredString(errors, run.terminal_result, "run_id", `${path}.run_id`);
1344
+ optionalString(errors, run.terminal_result, "pr_url", `${path}.pr_url`);
1345
+ optionalString(errors, run.terminal_result, "reason", `${path}.reason`);
1346
+ optionalString(errors, run.terminal_result, "summary", `${path}.summary`);
1347
+ validateStringMap(errors, run.terminal_result.artifacts, `${path}.artifacts`);
1348
+ if (run.terminal_result.status && run.terminal_result.status !== run.status) errors.push({ path: `${path}.status`, message: `must match run.status '${run.status}'` });
1349
+ if (run.terminal_result.run_id && run.terminal_result.run_id !== run.run_id) errors.push({ path: `${path}.run_id`, message: "must match run.run_id" });
1350
+ if (["blocked", "partial", "needs-human"].includes(run.status) && !stringValue(run.terminal_result.reason)) errors.push({ path: `${path}.reason`, message: `is required for ${run.status}` });
1351
+ }
1352
+
1353
+ function validateCostAttribution(errors, attribution, path, run) {
1354
+ if (attribution === undefined || attribution === null) return;
1355
+ if (!isRecord(attribution)) {
1356
+ errors.push({ path, message: "must be an object" });
1357
+ return;
1358
+ }
1359
+
1360
+ requiredInteger(errors, attribution, "schema_version", `${path}.schema_version`);
1361
+ if (Number.isInteger(attribution.schema_version) && attribution.schema_version !== COST_ATTRIBUTION_SCHEMA_VERSION) errors.push({ path: `${path}.schema_version`, message: `must equal ${COST_ATTRIBUTION_SCHEMA_VERSION}` });
1362
+ requiredString(errors, attribution, "updated_at", `${path}.updated_at`);
1363
+ requiredEnum(errors, attribution, "status", COST_ATTRIBUTION_STATUS_SET, `${path}.status`);
1364
+ validateCostAttributionRollup(errors, attribution.totals, `${path}.totals`, { required: true });
1365
+ validateCostAttributionRollupMap(errors, attribution.by_agent, `${path}.by_agent`, { required: true });
1366
+ validateCostAttributionRollupMap(errors, attribution.by_slice, `${path}.by_slice`, { required: true, knownKeys: knownRunSliceIds(run) });
1367
+
1368
+ const knownSlices = knownRunSliceIds(run);
1369
+ const runId = stringValue(run?.run_id) ? run.run_id : null;
1370
+ if (!Array.isArray(attribution.entries)) {
1371
+ errors.push({ path: `${path}.entries`, message: "must be an array" });
1372
+ return;
1373
+ }
1374
+ if (attribution.entries.length > MAX_COST_ATTRIBUTION_ENTRIES) errors.push({ path: `${path}.entries`, message: `must have at most ${MAX_COST_ATTRIBUTION_ENTRIES} entries` });
1375
+ for (const [index, entry] of attribution.entries.entries()) validateCostAttributionEntry(errors, entry, `${path}.entries[${index}]`, knownSlices, runId);
1376
+ }
1377
+
1378
+ function validateCostAttributionEntry(errors, entry, path, knownSlices, runId) {
1379
+ if (!isRecord(entry)) {
1380
+ errors.push({ path, message: "must be an object" });
1381
+ return;
1382
+ }
1383
+ requiredString(errors, entry, "id", `${path}.id`);
1384
+ requiredString(errors, entry, "recorded_at", `${path}.recorded_at`);
1385
+ requiredString(errors, entry, "run_id", `${path}.run_id`);
1386
+ requiredString(errors, entry, "agent", `${path}.agent`);
1387
+ requiredEnum(errors, entry, "status", COST_ATTRIBUTION_STATUS_SET, `${path}.status`);
1388
+ validateStringArray(errors, entry.missing, `${path}.missing`, { required: true, noControlChars: true });
1389
+
1390
+ for (const field of COST_ATTRIBUTION_ENTRY_OPTIONAL_STRINGS) optionalString(errors, entry, field, `${path}.${field}`);
1391
+ optionalCostCurrency(errors, entry, "cost_currency", `${path}.cost_currency`);
1392
+ validateCostAttributionNumbers(errors, entry, path);
1393
+ if (hasCostNumber(entry) && !stringValue(entry.cost_currency)) errors.push({ path: `${path}.cost_currency`, message: "is required when cost fields are present" });
1394
+ if (runId && stringValue(entry.run_id) && entry.run_id !== runId) errors.push({ path: `${path}.run_id`, message: "must match run.run_id" });
1395
+ validateCostAttributionAvailability(errors, entry, path);
1396
+ if ((entry.status === "partial" || entry.status === "unavailable") && Array.isArray(entry.missing) && !hasNonEmptyStringItem(entry.missing)) errors.push({ path: `${path}.missing`, message: `is required when status is ${entry.status}` });
1397
+ if (stringValue(entry.slice_id) && knownSlices && !knownSlices.has(entry.slice_id)) errors.push({ path: `${path}.slice_id`, message: `unknown slice '${entry.slice_id}'` });
1398
+ }
1399
+
1400
+ function validateCostAttributionAvailability(errors, entry, path) {
1401
+ const missing = costAttributionAvailabilityMissing(entry);
1402
+ const hasUsage = hasUsageNumber(entry);
1403
+ const hasCost = hasCostNumber(entry);
1404
+ if (entry.status === "available" && Array.isArray(entry.missing) && entry.missing.length > 0) {
1405
+ errors.push({ path: `${path}.missing`, message: "must be empty when status is available" });
1406
+ }
1407
+ if (entry.status === "available" && missing.length > 0) {
1408
+ errors.push({ path: `${path}.status`, message: "available requires provider, model, usage, cost_total, and cost_currency" });
1409
+ }
1410
+ if (entry.status === "unavailable" && (hasUsage || hasCost)) errors.push({ path: `${path}.status`, message: "must be partial or available when usage or cost fields are present" });
1411
+ }
1412
+
1413
+ function costAttributionAvailabilityMissing(entry) {
1414
+ const missing = [];
1415
+ if (!stringValue(entry.provider)) missing.push("provider");
1416
+ if (!stringValue(entry.model)) missing.push("model");
1417
+ if (!hasUsageNumber(entry)) missing.push("usage");
1418
+ if (entry.cost_total === undefined || entry.cost_total === null) missing.push("cost_total");
1419
+ if (!stringValue(entry.cost_currency)) missing.push("cost_currency");
1420
+ return missing;
1421
+ }
1422
+
1423
+ function validateCostAttributionRollupMap(errors, map, path, options = {}) {
1424
+ if (!isRecord(map)) {
1425
+ if (options.required) errors.push({ path, message: "must be an object" });
1426
+ return;
1427
+ }
1428
+ for (const [key, rollup] of Object.entries(map)) {
1429
+ if (!stringValue(key)) errors.push({ path: `${path}.${key}`, message: "must be keyed by non-empty strings" });
1430
+ if (options.knownKeys && !options.knownKeys.has(key)) errors.push({ path: `${path}.${key}`, message: `unknown slice '${key}'` });
1431
+ validateCostAttributionRollup(errors, rollup, `${path}.${key}`, { required: true });
1432
+ }
1433
+ }
1434
+
1435
+ function validateCostAttributionRollup(errors, rollup, path, options = {}) {
1436
+ if (!isRecord(rollup)) {
1437
+ if (options.required) errors.push({ path, message: "must be an object" });
1438
+ return;
1439
+ }
1440
+ requiredEnum(errors, rollup, "status", COST_ATTRIBUTION_STATUS_SET, `${path}.status`);
1441
+ requiredInteger(errors, rollup, "entry_count", `${path}.entry_count`);
1442
+ optionalInteger(errors, rollup, "request_count", `${path}.request_count`);
1443
+ validateStringArray(errors, rollup.missing, `${path}.missing`, { required: true, noControlChars: true });
1444
+ optionalBoolean(errors, rollup, "mixed_currency", `${path}.mixed_currency`);
1445
+ optionalString(errors, rollup, "cost_currency", `${path}.cost_currency`);
1446
+ optionalCostCurrency(errors, rollup, "cost_currency", `${path}.cost_currency`);
1447
+ validateCostAttributionNumbers(errors, rollup, path);
1448
+ if (rollup.mixed_currency === true && rollup.cost_total !== undefined && rollup.cost_total !== null) errors.push({ path: `${path}.cost_total`, message: "must be omitted when mixed_currency is true" });
1449
+ if (hasCostNumber(rollup) && rollup.mixed_currency !== true && rollup.cost_total !== undefined && !stringValue(rollup.cost_currency)) errors.push({ path: `${path}.cost_currency`, message: "is required when cost_total is present" });
1450
+ }
1451
+
1452
+ function validateCostAttributionNumbers(errors, value, path) {
1453
+ for (const field of COST_ATTRIBUTION_NUMERIC_FIELDS) {
1454
+ if (value[field] === undefined || value[field] === null) continue;
1455
+ if (typeof value[field] !== "number" || !Number.isFinite(value[field]) || value[field] < 0) errors.push({ path: `${path}.${field}`, message: "must be a finite non-negative number" });
1456
+ }
1457
+ }
1458
+
1459
+ function knownRunSliceIds(run) {
1460
+ if (!Array.isArray(run?.slices)) return null;
1461
+ const ids = new Set();
1462
+ for (const slice of run.slices) if (stringValue(slice?.id)) ids.add(slice.id);
1463
+ return ids;
1464
+ }
1465
+
1466
+ function hasCostNumber(value) {
1467
+ return COST_NUMERIC_FIELDS.some((field) => value[field] !== undefined && value[field] !== null);
1468
+ }
1469
+
1470
+ function hasUsageNumber(value) {
1471
+ return USAGE_NUMERIC_FIELDS.some((field) => value[field] !== undefined && value[field] !== null);
1472
+ }
1473
+
1474
+ function validateGateName(errors, name, path) {
1475
+ if (!SAFE_GATE_NAME_PATTERN.test(name)) errors.push({ path, message: "must match safe gate name pattern [a-z0-9][a-z0-9_-]*[a-z0-9]" });
1476
+ }
1477
+
1478
+ function validateStringMap(errors, value, path) {
1479
+ if (value === undefined || value === null) return;
1480
+ if (!isRecord(value)) {
1481
+ errors.push({ path, message: "must be an object" });
1482
+ return;
1483
+ }
1484
+ for (const [key, item] of Object.entries(value)) if (typeof item !== "string") errors.push({ path: `${path}.${key}`, message: "must be a string" });
1485
+ }
1486
+
1487
+ function validateProcessLogRefContainment(errors, ref, path, runDir) {
1488
+ if (!stringValue(ref) || !stringValue(runDir)) return;
1489
+ const processesDir = resolve(processEvidenceProcessesDir(runDir));
1490
+ const resolvedLog = resolve(runDir, ref);
1491
+ const relativeLog = relative(processesDir, resolvedLog);
1492
+ if (relativeLog === "" || relativeLog === ".." || relativeLog.startsWith(`..${sep}`)) {
1493
+ errors.push({ path, message: "must stay under run processes directory" });
1494
+ }
1495
+ }
1496
+
1497
+ function validateRequiredStringMap(errors, value, path) {
1498
+ if (!isRecord(value)) {
1499
+ errors.push({ path, message: "must be an object" });
1500
+ return;
1501
+ }
1502
+ for (const [key, item] of Object.entries(value)) if (!stringValue(item)) errors.push({ path: `${path}.${key}`, message: "must be a non-empty string" });
1503
+ }
1504
+
1505
+ function validateRequiredHashMap(errors, value, path) {
1506
+ if (!isRecord(value)) {
1507
+ errors.push({ path, message: "must be an object" });
1508
+ return;
1509
+ }
1510
+ for (const [key, item] of Object.entries(value)) if (typeof item !== "string" || !HASH_PATTERN.test(item)) errors.push({ path: `${path}.${key}`, message: "must be a sha256 hash" });
1511
+ }
1512
+
1513
+ function validateStringArray(errors, value, path, options = {}) {
1514
+ if (value === undefined || value === null) {
1515
+ if (options.required) errors.push({ path, message: "must be an array" });
1516
+ return;
1517
+ }
1518
+ if (!Array.isArray(value)) {
1519
+ errors.push({ path, message: "must be an array" });
1520
+ return;
1521
+ }
1522
+ if (options.nonEmpty && value.length === 0) errors.push({ path, message: "must not be empty" });
1523
+ for (const [index, item] of value.entries()) {
1524
+ if (!stringValue(item)) errors.push({ path: `${path}[${index}]`, message: "must be a non-empty string" });
1525
+ else if (options.noControlChars && hasTerminalControl(item)) errors.push({ path: `${path}[${index}]`, message: "must not contain control characters" });
1526
+ else if (options.values && !options.values.has(item)) errors.push({ path: `${path}[${index}]`, message: `unknown dependency '${item}'` });
1527
+ }
1528
+ }
1529
+
1530
+ function hasNonEmptyStringItem(value) {
1531
+ return Array.isArray(value) && value.some((item) => stringValue(item));
1532
+ }
1533
+
1534
+ function requiredString(errors, obj, key, path) {
1535
+ if (!stringValue(obj[key])) errors.push({ path, message: "must be a non-empty string" });
1536
+ }
1537
+
1538
+ function requiredTerminalSafeString(errors, obj, key, path) {
1539
+ requiredString(errors, obj, key, path);
1540
+ if (typeof obj[key] === "string" && hasTerminalControl(obj[key])) errors.push({ path, message: "must not contain control characters" });
1541
+ }
1542
+
1543
+ function optionalString(errors, obj, key, path) {
1544
+ if (obj[key] === undefined || obj[key] === null) return;
1545
+ if (typeof obj[key] !== "string") errors.push({ path, message: "must be a string or null" });
1546
+ }
1547
+
1548
+ function optionalCostCurrency(errors, obj, key, path) {
1549
+ if (obj[key] === undefined || obj[key] === null) return;
1550
+ if (typeof obj[key] === "string" && !isSafeCostCurrency(obj[key])) errors.push({ path, message: "must be an uppercase currency code (3-12 letters) with no control characters" });
1551
+ }
1552
+
1553
+ function optionalNonEmptyString(errors, obj, key, path) {
1554
+ if (obj[key] === undefined || obj[key] === null) return;
1555
+ if (!stringValue(obj[key])) errors.push({ path, message: "must be a non-empty string" });
1556
+ }
1557
+
1558
+ function requiredEnum(errors, obj, key, values, path) {
1559
+ if (typeof obj[key] !== "string" || !values.has(obj[key])) errors.push({ path, message: `must be one of ${[...values].join(", ")}` });
1560
+ }
1561
+
1562
+ function optionalEnum(errors, obj, key, values, path) {
1563
+ if (obj[key] === undefined || obj[key] === null) return;
1564
+ requiredEnum(errors, obj, key, values, path);
1565
+ }
1566
+
1567
+ function optionalNumber(errors, obj, key, path) {
1568
+ if (obj[key] === undefined || obj[key] === null) return;
1569
+ if (typeof obj[key] !== "number" || !Number.isFinite(obj[key])) errors.push({ path, message: "must be a number" });
1570
+ }
1571
+
1572
+ function optionalInteger(errors, obj, key, path) {
1573
+ if (obj[key] === undefined || obj[key] === null) return;
1574
+ if (!Number.isInteger(obj[key]) || obj[key] < 0) errors.push({ path, message: "must be a non-negative integer" });
1575
+ }
1576
+
1577
+ function optionalBoolean(errors, obj, key, path) {
1578
+ if (obj[key] === undefined || obj[key] === null) return;
1579
+ if (typeof obj[key] !== "boolean") errors.push({ path, message: "must be a boolean" });
1580
+ }
1581
+
1582
+ function requiredBoolean(errors, obj, key, path) {
1583
+ if (typeof obj[key] !== "boolean") errors.push({ path, message: "must be a boolean" });
1584
+ }
1585
+
1586
+ function boundedInteger(errors, obj, key, min, max, path) {
1587
+ if (!Number.isInteger(obj?.[key]) || obj[key] < min || obj[key] > max) errors.push({ path, message: `must be an integer from ${min} to ${max}` });
1588
+ }
1589
+
1590
+ function optionalNullableString(errors, obj, key, path) {
1591
+ if (obj[key] === undefined || obj[key] === null) return;
1592
+ if (!stringValue(obj[key])) errors.push({ path, message: "must be a non-empty string or null" });
1593
+ }
1594
+
1595
+ function optionalNullableEnum(errors, obj, key, values, path) {
1596
+ if (obj[key] === undefined || obj[key] === null) return;
1597
+ requiredEnum(errors, obj, key, values, path);
1598
+ }
1599
+
1600
+ function optionalNullableHash(errors, obj, key, path) {
1601
+ if (obj[key] === undefined || obj[key] === null) return;
1602
+ requiredHash(errors, obj, key, path);
1603
+ }
1604
+
1605
+ function requiredFullGitSha(errors, obj, key, path) {
1606
+ if (typeof obj[key] !== "string" || !FULL_GIT_SHA_PATTERN.test(obj[key])) errors.push({ path, message: "must be a full 40-character lowercase git SHA" });
1607
+ }
1608
+
1609
+ function optionalNullableFullGitSha(errors, obj, key, path) {
1610
+ if (obj[key] === undefined || obj[key] === null) return;
1611
+ requiredFullGitSha(errors, obj, key, path);
1612
+ }
1613
+
1614
+ function allowedKeys(errors, value, allowed, path) {
1615
+ for (const key of Object.keys(value)) if (!allowed.has(key)) errors.push({ path: `${path}.${key}`, message: "is not allowed" });
1616
+ }
1617
+
1618
+ function requiredInteger(errors, obj, key, path) {
1619
+ if (!Number.isInteger(obj[key]) || obj[key] < 0) errors.push({ path, message: "must be a non-negative integer" });
1620
+ }
1621
+
1622
+ function optionalNullableInteger(errors, obj, key, path) {
1623
+ if (obj[key] === null) return;
1624
+ requiredInteger(errors, obj, key, path);
1625
+ }
1626
+
1627
+ function requiredHash(errors, obj, key, path) {
1628
+ if (typeof obj[key] !== "string" || !HASH_PATTERN.test(obj[key])) errors.push({ path, message: "must be a sha256 hash" });
1629
+ }
1630
+
1631
+ function optionalHash(errors, obj, key, path) {
1632
+ if (obj[key] === undefined || obj[key] === null) return;
1633
+ requiredHash(errors, obj, key, path);
1634
+ }
1635
+
1636
+ function stringValue(value) {
1637
+ return typeof value === "string" && value.trim().length > 0;
1638
+ }
1639
+
1640
+ function safeValidationIdentifier(value) {
1641
+ return safeValidationText(value) || "<control characters removed>";
1642
+ }
1643
+
1644
+ function safeValidationText(value) {
1645
+ if (!hasTerminalControl(value)) return value;
1646
+ return sanitizePublicCostText(value);
1647
+ }
1648
+
1649
+ function fail(errors) {
1650
+ throw new ValidationError(errors);
1651
+ }
1652
+
1653
+ function isRecord(value) {
1654
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
1655
+ }