@namewta/speculo 0.8.9 → 0.8.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/structured.js +186 -0
- package/dist/src/structured.js.map +1 -1
- package/package.json +1 -1
- package/template/canonical/canonical-specdev-goal-plan.md +14 -4
- package/template/canonical/canonical-specdev-orchestrate-implementation.md +27 -7
- package/template/canonical/canonical-specdev-tickets.md +5 -2
- package/template/commands/archive-and-consolidate.md +9 -0
- package/template/commands/status.md +3 -3
- package/template/workflows/ops/A-archive-and-learn/A-archive-and-learn.md +64 -0
- package/template/workflows/ops/A-archive-and-learn/promotion-plan-template.md +41 -0
- package/template/workflows/ops/A-archive-and-learn/retrospective-template.md +53 -0
- package/template/workflows/ops/E-execute-and-stabilize/E-execute-and-stabilize.md +75 -0
- package/template/workflows/ops/E-execute-and-stabilize/attempt-summary-template.md +44 -0
- package/template/workflows/ops/E-execute-and-stabilize/diagnosis-template.md +24 -0
- package/template/workflows/ops/E-execute-and-stabilize/handoff-template.md +32 -0
- package/template/workflows/ops/E-execute-and-stabilize/rollback-template.md +26 -0
- package/template/workflows/ops/E-execute-and-stabilize/verification-state-template.json +30 -0
- package/template/workflows/ops/E-execute-and-stabilize/verification-template.md +55 -0
- package/template/workflows/ops/I-intake-and-assess/I-intake-and-assess.md +66 -0
- package/template/workflows/ops/I-intake-and-assess/change-status-template.json +26 -0
- package/template/workflows/ops/I-intake-and-assess/collector-catalog.md +28 -0
- package/template/workflows/ops/I-intake-and-assess/deployment-dossier-template.md +62 -0
- package/template/workflows/ops/I-intake-and-assess/global-change-status-template.json +26 -0
- package/template/workflows/ops/I-intake-and-assess/project-detection.md +38 -0
- package/template/workflows/ops/I-intake-and-assess/request-template.md +44 -0
- package/template/workflows/ops/I-intake-and-assess/system-report-template.md +34 -0
- package/template/workflows/ops/I-intake-and-assess/target-profile-template.json +24 -0
- package/template/workflows/ops/INDEX.md +30 -0
- package/template/workflows/ops/P-plan-and-approve/P-plan-and-approve.md +65 -0
- package/template/workflows/ops/P-plan-and-approve/plan-review-template.md +78 -0
- package/template/workflows/ops/README.md +120 -0
- package/template/workflows/ops/_state/archive/.gitkeep +1 -0
- package/template/workflows/ops/_state/changes/.gitkeep +1 -0
- package/template/workflows/ops/_state/status.json +6 -0
- package/template/workflows/ops/common/rules/artifact-contract.md +37 -0
- package/template/workflows/ops/common/rules/closure-and-learning.md +25 -0
- package/template/workflows/ops/common/rules/evidence-and-redaction.md +22 -0
- package/template/workflows/ops/common/rules/execution-loop.md +27 -0
- package/template/workflows/ops/common/rules/path-and-scope-contract.md +21 -0
- package/template/workflows/ops/common/rules/plan-and-approval.md +25 -0
- package/template/workflows/ops/common/rules/project-and-change-scope.md +20 -0
- package/template/workflows/ops/common/rules/target-profile-and-release-gates.md +44 -0
- package/template/workflows/ops/common/schemas/approval.schema.json +28 -0
- package/template/workflows/ops/common/schemas/attempt.schema.json +42 -0
- package/template/workflows/ops/common/schemas/change-status.schema.json +43 -0
- package/template/workflows/ops/common/schemas/deployment-model.schema.json +26 -0
- package/template/workflows/ops/common/schemas/implementation-plan.schema.json +221 -0
- package/template/workflows/ops/common/schemas/inventory-snapshot.schema.json +31 -0
- package/template/workflows/ops/common/schemas/journal-event.schema.json +22 -0
- package/template/workflows/ops/common/schemas/project.schema.json +19 -0
- package/template/workflows/ops/common/schemas/promotion-approval.schema.json +20 -0
- package/template/workflows/ops/common/schemas/promotion-manifest.schema.json +22 -0
- package/template/workflows/ops/common/schemas/status.schema.json +30 -0
- package/template/workflows/ops/common/schemas/target-profile.schema.json +32 -0
- package/template/workflows/ops/common/schemas/verification-state.schema.json +30 -0
- package/template/workflows/ops/common/tools/close-change.mjs +177 -0
- package/template/workflows/ops/common/tools/validate-ops.mjs +992 -0
- package/template/workflows/ops/runtime-contract.json +14 -0
- package/template/workflows/specdev/I-implement/I-implement.md +6 -3
- package/template/workflows/specdev/I-implement/evidence-template.md +13 -0
- package/template/workflows/specdev/O-orchestrate-implementation/execution-loop.md +3 -2
- package/template/workflows/specdev/P-goal-plan/completion-control.md +3 -0
- package/template/workflows/specdev/P-goal-plan/lead-orchestration.md +6 -2
- package/template/workflows/specdev/common/skills/dev-worktree/references/finalize.md +5 -2
|
@@ -0,0 +1,992 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { existsSync, lstatSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
5
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
const CHANGE_NAME = /^[0-9]{4}-[0-9]{2}-[0-9]{2}-[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
9
|
+
const PROJECT_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
10
|
+
const DIGEST = /^[a-f0-9]{64}$/;
|
|
11
|
+
const IMMUTABLE_DIGEST = /^(?:sha256:)?[a-f0-9]{64}$/;
|
|
12
|
+
const PLAN_LOCATOR = /^plan\/plan-[0-9]{3}\.json$/;
|
|
13
|
+
const APPROVAL_LOCATOR = /^plan\/approval-[0-9]{3}\.json$/;
|
|
14
|
+
const ATTEMPT_ID = /^ATTEMPT-[0-9]{3}$/;
|
|
15
|
+
const TARGET_PROFILE_LOCATOR = /^deployment\/target-profile\.json$/;
|
|
16
|
+
const VERIFICATION_STATE_LOCATOR = /^execution\/attempts\/ATTEMPT-[0-9]{3}\/verification-state\.json$/;
|
|
17
|
+
const HANDOFF_LOCATOR = /^execution\/attempts\/ATTEMPT-[0-9]{3}\/HANDOFF\.md$/;
|
|
18
|
+
const BATCH_ID = /^B[0-9]{2}$/;
|
|
19
|
+
const OPERATION_ID = /^OP[0-9]{3}$/;
|
|
20
|
+
const GATE_ID = /^G[0-9]{2}$/;
|
|
21
|
+
const VERIFICATION_ID = /^VR[0-9]{3}$/;
|
|
22
|
+
const CHANGE_STATUS = new Set(["active", "blocked", "completed", "archived"]);
|
|
23
|
+
const PHASE = new Set(["intake", "assessment", "planning", "awaiting_approval", "approved", "executing", "diagnosing", "stabilizing", "ready_to_archive", "archived"]);
|
|
24
|
+
const APPROVAL_STATUS = new Set(["not_requested", "pending", "approved", "invalidated"]);
|
|
25
|
+
const OUTCOME = new Set(["pending", "succeeded", "rolled_back", "abandoned"]);
|
|
26
|
+
const EXPECTED_WORKS = new Set(["A-archive-and-learn", "E-execute-and-stabilize", "I-intake-and-assess", "P-plan-and-approve"]);
|
|
27
|
+
const EXPECTED_WORK_IDS = new Set(["ops/archive-and-learn", "ops/execute-and-stabilize", "ops/intake-and-assess", "ops/plan-and-approve"]);
|
|
28
|
+
const RETROSPECTIVE_HEADINGS = [
|
|
29
|
+
"## Attempt Timeline", "## Errors and Failure Signatures", "## Confirmed Root Causes",
|
|
30
|
+
"## Rejected Hypotheses and Why", "## Final Effective Deployment or Recovery Sequence",
|
|
31
|
+
"## Lessons and Cautions", "## SOP and Troubleshooting Candidates", "## Remaining Risks and Unknowns",
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
function parseArgs(argv) {
|
|
35
|
+
const options = { workflowRoot: null, stateRoot: null, stage: null, scope: null, project: null, change: null, selfCheck: false, digest: null };
|
|
36
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
37
|
+
const arg = argv[index];
|
|
38
|
+
if (arg === "--workflow-root") options.workflowRoot = resolve(argv[++index] ?? "");
|
|
39
|
+
else if (arg === "--state-root") options.stateRoot = resolve(argv[++index] ?? "");
|
|
40
|
+
else if (arg === "--stage") options.stage = argv[++index] ?? null;
|
|
41
|
+
else if (arg === "--scope") options.scope = argv[++index] ?? null;
|
|
42
|
+
else if (arg === "--project") options.project = argv[++index] ?? null;
|
|
43
|
+
else if (arg === "--change") options.change = argv[++index] ?? null;
|
|
44
|
+
else if (arg === "--self-check") options.selfCheck = true;
|
|
45
|
+
else if (arg === "--digest") options.digest = resolve(argv[++index] ?? "");
|
|
46
|
+
else throw new Error(`unknown option: ${arg}`);
|
|
47
|
+
}
|
|
48
|
+
if (!options.workflowRoot && !options.stateRoot && !options.selfCheck && !options.digest) throw new Error("use --workflow-root, --state-root, --self-check, or --digest");
|
|
49
|
+
if (options.stage && !new Set(["pre-execute", "pre-close", "pre-archive", "complete"]).has(options.stage)) throw new Error("--stage must be pre-execute, pre-close, pre-archive, or complete");
|
|
50
|
+
if (options.stage && (!options.scope || !options.change)) throw new Error("--stage requires --scope and --change");
|
|
51
|
+
if (options.scope && !["global", "project"].includes(options.scope)) throw new Error("--scope must be global or project");
|
|
52
|
+
if (options.scope === "project" && !PROJECT_ID.test(options.project ?? "")) throw new Error("project scope requires a valid --project");
|
|
53
|
+
if (options.scope === "global" && options.project) throw new Error("global scope cannot use --project");
|
|
54
|
+
if (options.change && !CHANGE_NAME.test(options.change)) throw new Error("--change has an invalid name");
|
|
55
|
+
return options;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isFile(path) { return existsSync(path) && statSync(path).isFile(); }
|
|
59
|
+
function isDirectory(path) { return existsSync(path) && statSync(path).isDirectory(); }
|
|
60
|
+
function validDateTime(value) { return typeof value === "string" && !Number.isNaN(Date.parse(value)); }
|
|
61
|
+
function canonicalize(value) {
|
|
62
|
+
if (Array.isArray(value)) return value.map(canonicalize);
|
|
63
|
+
if (value && typeof value === "object") return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalize(value[key])]));
|
|
64
|
+
return value;
|
|
65
|
+
}
|
|
66
|
+
function canonicalDigest(value) { return createHash("sha256").update(JSON.stringify(canonicalize(value))).digest("hex"); }
|
|
67
|
+
function fileDigest(path) { return createHash("sha256").update(readFileSync(path)).digest("hex"); }
|
|
68
|
+
function duplicates(values) { const seen = new Set(); return values.filter((value) => seen.has(value) || !seen.add(value)); }
|
|
69
|
+
function sameMembers(left, right) { return left.size === right.size && [...left].every((item) => right.has(item)); }
|
|
70
|
+
function isObject(value) { return Boolean(value) && typeof value === "object" && !Array.isArray(value); }
|
|
71
|
+
function nonEmptyString(value) { return typeof value === "string" && Boolean(value.trim()); }
|
|
72
|
+
function validStringList(value, { minimum = 0 } = {}) { return Array.isArray(value) && value.length >= minimum && value.every(nonEmptyString) && duplicates(value).length === 0; }
|
|
73
|
+
function sameOrderedValues(left, right) { return Array.isArray(left) && Array.isArray(right) && left.length === right.length && left.every((value, index) => value === right[index]); }
|
|
74
|
+
function normalizedDigest(value) { return typeof value === "string" ? value.replace(/^sha256:/, "") : value; }
|
|
75
|
+
function pathInsideOrEqual(root, candidate) { if (!isAbsolute(root ?? "") || !isAbsolute(candidate ?? "")) return false; const rel = relative(resolve(root), resolve(candidate)); return rel === "" || !(rel === ".." || rel.startsWith(".." + sep) || isAbsolute(rel)); }
|
|
76
|
+
function pathsOverlap(left, right) { return pathInsideOrEqual(left, right) || pathInsideOrEqual(right, left); }
|
|
77
|
+
function unsafeAbsolutePath(value) { return !nonEmptyString(value) || !isAbsolute(value) || ["/", "/root", "/home", "/Users"].includes(resolve(value)) || value.includes("$") || value.split(/[\\/]/).includes(".."); }
|
|
78
|
+
function containsSecretMaterial(text) {
|
|
79
|
+
if (typeof text !== "string") return false;
|
|
80
|
+
if (/-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/i.test(text) || /\b[a-z][a-z0-9+.-]*:\/\/[^/\s@]+@/i.test(text)) return true;
|
|
81
|
+
const credentialAssignment = /(?:^|[\s"'`])(?:[A-Z0-9_]*(?:PASSWORD|PASSWD|SECRET|TOKEN|API_KEY|PRIVATE_KEY|ACCESS_KEY)[A-Z0-9_]*|AUTHORIZATION)\s*[:=]\s*(?:"([^"]*)"|'([^']*)'|([^\s,;]+))/gim;
|
|
82
|
+
for (const match of text.matchAll(credentialAssignment)) {
|
|
83
|
+
const assigned = (match[1] ?? match[2] ?? match[3] ?? "").trim();
|
|
84
|
+
if (assigned && !/^(?:<|\[)?(?:redacted|secret[-_ ]?ref|credential[-_ ]?ref|managed[-_ ]?externally)(?:>|\])?$/i.test(assigned) && !/^\$\{[A-Z0-9_]+\}$/i.test(assigned)) return true;
|
|
85
|
+
}
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function readJson(path, label, errors) {
|
|
90
|
+
try { return JSON.parse(readFileSync(path, "utf8")); }
|
|
91
|
+
catch (error) { errors.push(`${label}: invalid JSON (${error.message})`); return null; }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function exactKeys(value, keys, label, errors) {
|
|
95
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) { errors.push(`${label}: must be an object`); return false; }
|
|
96
|
+
const actual = Object.keys(value).sort();
|
|
97
|
+
const expected = [...keys].sort();
|
|
98
|
+
if (actual.join("\n") !== expected.join("\n")) { errors.push(`${label}: expected fields ${expected.join(", ")}; found ${actual.join(", ")}`); return false; }
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function walkIncludingDirectories(root) {
|
|
103
|
+
const paths = [];
|
|
104
|
+
if (!isDirectory(root)) return paths;
|
|
105
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
106
|
+
const path = join(root, entry.name);
|
|
107
|
+
paths.push(path);
|
|
108
|
+
if (entry.isDirectory() && !lstatSync(path).isSymbolicLink()) paths.push(...walkIncludingDirectories(path));
|
|
109
|
+
}
|
|
110
|
+
return paths;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function validateNoSymlinks(root, errors) {
|
|
114
|
+
for (const path of walkIncludingDirectories(root)) if (lstatSync(path).isSymbolicLink()) errors.push(`${relative(root, path)}: Ops state must not contain symlinks`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function entryKey(entry) { return `${entry.scope}:${entry.project_id ?? "-"}:${entry.change}`; }
|
|
118
|
+
function projectRoot(stateRoot, project) { return join(stateRoot, "projects", project); }
|
|
119
|
+
function activeRoot(stateRoot, entry) { return entry.scope === "global" ? join(stateRoot, "changes", entry.change) : join(projectRoot(stateRoot, entry.project_id), "changes", entry.change); }
|
|
120
|
+
function archivedRoot(stateRoot, entry) { return entry.scope === "global" ? join(stateRoot, "archive", entry.change.slice(0, 7), entry.change) : join(projectRoot(stateRoot, entry.project_id), "archive", entry.change.slice(0, 7), entry.change); }
|
|
121
|
+
function stateRelativeActive(entry) { return entry.scope === "global" ? `changes/${entry.change}` : `projects/${entry.project_id}/changes/${entry.change}`; }
|
|
122
|
+
function stateRelativeArchive(entry) { return entry.scope === "global" ? `archive/${entry.change.slice(0, 7)}/${entry.change}` : `projects/${entry.project_id}/archive/${entry.change.slice(0, 7)}/${entry.change}`; }
|
|
123
|
+
function archivePathTag(entry) { return `<Path>{roots.state}/ops/${stateRelativeArchive(entry)}</Path>`; }
|
|
124
|
+
|
|
125
|
+
function validateEntry(value, label, errors) {
|
|
126
|
+
if (!exactKeys(value, ["scope", "project_id", "change"], label, errors)) return null;
|
|
127
|
+
if (!["global", "project"].includes(value.scope) || !CHANGE_NAME.test(value.change ?? "")) { errors.push(`${label}: invalid scope or change`); return null; }
|
|
128
|
+
if ((value.scope === "global" && value.project_id !== null) || (value.scope === "project" && !PROJECT_ID.test(value.project_id ?? ""))) { errors.push(`${label}: project_id disagrees with scope`); return null; }
|
|
129
|
+
return { scope: value.scope, project_id: value.project_id, change: value.change };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function validateWorkflowRoot(root, errors) {
|
|
133
|
+
const required = [
|
|
134
|
+
"INDEX.md", "README.md", "runtime-contract.json", "_state/status.json",
|
|
135
|
+
"common/rules/artifact-contract.md", "common/rules/project-and-change-scope.md", "common/rules/path-and-scope-contract.md",
|
|
136
|
+
"common/rules/evidence-and-redaction.md", "common/rules/plan-and-approval.md", "common/rules/execution-loop.md", "common/rules/closure-and-learning.md",
|
|
137
|
+
"common/rules/target-profile-and-release-gates.md",
|
|
138
|
+
"common/schemas/status.schema.json", "common/schemas/change-status.schema.json", "common/schemas/project.schema.json",
|
|
139
|
+
"common/schemas/inventory-snapshot.schema.json", "common/schemas/deployment-model.schema.json", "common/schemas/implementation-plan.schema.json",
|
|
140
|
+
"common/schemas/approval.schema.json", "common/schemas/attempt.schema.json", "common/schemas/target-profile.schema.json",
|
|
141
|
+
"common/schemas/journal-event.schema.json", "common/schemas/verification-state.schema.json", "common/schemas/promotion-manifest.schema.json",
|
|
142
|
+
"common/schemas/promotion-approval.schema.json", "common/tools/close-change.mjs",
|
|
143
|
+
"I-intake-and-assess/target-profile-template.json", "E-execute-and-stabilize/verification-state-template.json",
|
|
144
|
+
"E-execute-and-stabilize/handoff-template.md",
|
|
145
|
+
];
|
|
146
|
+
for (const item of required) if (!isFile(join(root, item))) errors.push(`${item}: required Ops workflow file is missing`);
|
|
147
|
+
if (errors.length) return;
|
|
148
|
+
const index = readFileSync(join(root, "INDEX.md"), "utf8");
|
|
149
|
+
const readme = readFileSync(join(root, "README.md"), "utf8");
|
|
150
|
+
if (!/^id: ops$/m.test(index) || !/^type: workflow$/m.test(index) || !/^workflow: ops$/m.test(index)) errors.push("INDEX.md: identity must be ops/type workflow");
|
|
151
|
+
if (!index.includes("## 永久知识") || !index.includes("## Work 激活") || !index.includes("<Path>{roots.workflows}/ops/README.md</Path>")) errors.push("INDEX.md: passive discovery contract is incomplete");
|
|
152
|
+
if (index.includes("AUTO-INDEX-START") || index.includes("## 状态字段")) errors.push("INDEX.md: passive index contains activation contracts");
|
|
153
|
+
if ((readme.match(/AUTO-INDEX-START/g) ?? []).length !== 1 || (readme.match(/AUTO-INDEX-END/g) ?? []).length !== 1) errors.push("README.md: requires one AUTO-INDEX marker pair");
|
|
154
|
+
for (const heading of ["## Work 条目", "## 运行时根", "## 路径分配", "## 持久化约定", "## 启动协议", "## 状态字段", "## 副作用边界"]) if (!readme.includes(heading)) errors.push(`README.md: missing ${heading}`);
|
|
155
|
+
const workDirs = new Set(readdirSync(root, { withFileTypes: true }).filter((entry) => entry.isDirectory() && /^[A-Z]-/.test(entry.name)).map((entry) => entry.name));
|
|
156
|
+
if (!sameMembers(workDirs, EXPECTED_WORKS)) errors.push(`workflow works mismatch: expected ${[...EXPECTED_WORKS].sort().join(", ")}; found ${[...workDirs].sort().join(", ")}`);
|
|
157
|
+
for (const work of workDirs) {
|
|
158
|
+
const entryPath = join(root, work, `${work}.md`);
|
|
159
|
+
if (!isFile(entryPath)) { errors.push(`${work}: missing same-named entry`); continue; }
|
|
160
|
+
const text = readFileSync(entryPath, "utf8");
|
|
161
|
+
if (!/^type: workflow-entry$/m.test(text) || !/^workflow: ops$/m.test(text) || !text.includes("<Path>{roots.workflows}/ops/README.md</Path>")) errors.push(`${work}: invalid identity or activation pointer`);
|
|
162
|
+
}
|
|
163
|
+
const seed = readJson(join(root, "_state/status.json"), "_state/status.json", errors);
|
|
164
|
+
if (seed && (seed.schema_version !== 2 || seed.workflow !== "ops" || JSON.stringify(seed.active) !== "[]" || JSON.stringify(seed.archived) !== "[]")) errors.push("_state/status.json: expected empty Ops schema v2 status");
|
|
165
|
+
const runtime = readJson(join(root, "runtime-contract.json"), "runtime-contract.json", errors);
|
|
166
|
+
const structured = [".speculo/ops/status.json", ".speculo/ops/changes/*/.status.json", ".speculo/ops/archive/*/*/.status.json", ".speculo/ops/projects/*/project.json", ".speculo/ops/projects/*/changes/*/.status.json", ".speculo/ops/projects/*/archive/*/*/.status.json"];
|
|
167
|
+
if (runtime && (runtime.schema_version !== 1 || runtime.workflow !== "ops" || runtime.config !== null || runtime.opaque_default !== "preserve-byte-for-byte" || JSON.stringify(runtime.structured_state) !== JSON.stringify(structured))) errors.push("runtime-contract.json: invalid Ops v2 contract");
|
|
168
|
+
for (const schema of required.filter((item) => item.endsWith(".json"))) readJson(join(root, schema), schema, errors);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function validateGlobalStatus(status, errors) {
|
|
172
|
+
if (!exactKeys(status, ["schema_version", "workflow", "active", "archived"], "status.json", errors)) return null;
|
|
173
|
+
if (status.schema_version !== 2 || status.workflow !== "ops" || !Array.isArray(status.active) || !Array.isArray(status.archived)) { errors.push("status.json: expected Ops schema v2"); return null; }
|
|
174
|
+
const active = status.active.map((entry, index) => validateEntry(entry, `status.json active[${index}]`, errors)).filter(Boolean);
|
|
175
|
+
const archived = status.archived.map((entry, index) => validateEntry(entry, `status.json archived[${index}]`, errors)).filter(Boolean);
|
|
176
|
+
const activeKeys = active.map(entryKey); const archivedKeys = archived.map(entryKey);
|
|
177
|
+
if (duplicates(activeKeys).length || duplicates(archivedKeys).length || archivedKeys.some((key) => activeKeys.includes(key))) errors.push("status.json: duplicate or overlapping scope/project/change tuple");
|
|
178
|
+
return { active, archived };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function validateProject(value, project, errors) {
|
|
182
|
+
const label = `projects/${project}/project.json`;
|
|
183
|
+
if (!exactKeys(value, ["schema_version", "artifact", "project_id", "display_name", "aliases", "identities", "source_hints", "created_at", "updated_at"], label, errors)) return;
|
|
184
|
+
if (value.schema_version !== 1 || value.artifact !== "ops-project" || value.project_id !== project || !PROJECT_ID.test(project)) errors.push(`${label}: invalid identity`);
|
|
185
|
+
if (typeof value.display_name !== "string" || !value.display_name.trim() || !Array.isArray(value.aliases) || duplicates(value.aliases ?? []).length || !Array.isArray(value.identities) || !value.identities.length || !Array.isArray(value.source_hints)) errors.push(`${label}: project metadata is incomplete`);
|
|
186
|
+
if (!validDateTime(value.created_at) || !validDateTime(value.updated_at)) errors.push(`${label}: invalid timestamps`);
|
|
187
|
+
for (const identity of value.identities ?? []) {
|
|
188
|
+
if (!exactKeys(identity, ["kind", "value", "source"], `${label} identity`, errors)) continue;
|
|
189
|
+
if (!identity.value || /:\/\/[^/]*@/.test(identity.value) || /(?:token|password|secret)=/i.test(identity.value)) errors.push(`${label}: identity is empty or may contain credentials`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function validateChangeStatus(value, entry, archived, errors) {
|
|
194
|
+
const label = `${entryKey(entry)}/.status.json`;
|
|
195
|
+
const keys = ["schema_version", "artifact", "scope", "project_id", "change", "change_status", "phase", "current_work", "works_run", "created_at", "updated_at", "completed_at", "archived_at", "archive_path", "source_revision", "target_fingerprint", "plan_path", "plan_digest", "approval_path", "approval_status", "approved_batches", "latest_attempt_id", "outcome", "blockers"];
|
|
196
|
+
if (!exactKeys(value, keys, label, errors)) return;
|
|
197
|
+
if (value.schema_version !== 2 || value.artifact !== "ops-change-status" || value.scope !== entry.scope || value.project_id !== entry.project_id || value.change !== entry.change) errors.push(`${label}: identity/location mismatch`);
|
|
198
|
+
if (!CHANGE_STATUS.has(value.change_status) || !PHASE.has(value.phase)) errors.push(`${label}: invalid lifecycle`);
|
|
199
|
+
if (!(value.current_work === null || EXPECTED_WORK_IDS.has(value.current_work))) errors.push(`${label}: invalid current_work`);
|
|
200
|
+
if (!Array.isArray(value.works_run) || value.works_run.some((id) => !EXPECTED_WORK_IDS.has(id)) || duplicates(value.works_run ?? []).length) errors.push(`${label}: invalid works_run`);
|
|
201
|
+
if (!validDateTime(value.created_at) || !validDateTime(value.updated_at) || !(value.completed_at === null || validDateTime(value.completed_at)) || !(value.archived_at === null || validDateTime(value.archived_at))) errors.push(`${label}: invalid timestamps`);
|
|
202
|
+
if (!(value.source_revision === null || (typeof value.source_revision === "string" && value.source_revision)) || !(value.target_fingerprint === null || DIGEST.test(value.target_fingerprint))) errors.push(`${label}: invalid source/target projection`);
|
|
203
|
+
if (!(value.plan_path === null || PLAN_LOCATOR.test(value.plan_path)) || !(value.plan_digest === null || DIGEST.test(value.plan_digest)) || ((value.plan_path === null) !== (value.plan_digest === null))) errors.push(`${label}: invalid plan projection`);
|
|
204
|
+
if (!(value.approval_path === null || APPROVAL_LOCATOR.test(value.approval_path)) || !APPROVAL_STATUS.has(value.approval_status)) errors.push(`${label}: invalid approval projection`);
|
|
205
|
+
if (!Array.isArray(value.approved_batches) || value.approved_batches.some((id) => !/^B[0-9]{2}$/.test(id)) || duplicates(value.approved_batches ?? []).length) errors.push(`${label}: invalid approved_batches`);
|
|
206
|
+
if (!(value.latest_attempt_id === null || ATTEMPT_ID.test(value.latest_attempt_id)) || !OUTCOME.has(value.outcome) || !Array.isArray(value.blockers) || value.blockers.some((item) => typeof item !== "string" || !item.trim()) || duplicates(value.blockers ?? []).length) errors.push(`${label}: invalid attempt/outcome/blockers`);
|
|
207
|
+
if (["not_requested", "pending"].includes(value.approval_status) && (value.approval_path !== null || value.approved_batches.length)) errors.push(`${label}: unapproved state cannot point to approval evidence`);
|
|
208
|
+
if (value.approval_status === "pending" && value.plan_path === null) errors.push(`${label}: pending approval requires a plan`);
|
|
209
|
+
if (value.approval_status === "approved" && (value.plan_path === null || value.approval_path === null || !value.approved_batches.length)) errors.push(`${label}: approved state requires plan, approval and batches`);
|
|
210
|
+
if (["completed", "archived"].includes(value.change_status)) {
|
|
211
|
+
const allowedTerminalWork = archived ? value.current_work === null : value.current_work === null || value.current_work === "ops/archive-and-learn";
|
|
212
|
+
if (!validDateTime(value.completed_at) || value.outcome === "pending" || value.blockers.length || !allowedTerminalWork) errors.push(`${label}: terminal change lacks completion evidence`);
|
|
213
|
+
} else if (value.completed_at !== null) errors.push(`${label}: incomplete change must keep completed_at null`);
|
|
214
|
+
if (archived) {
|
|
215
|
+
if (value.change_status !== "archived" || value.phase !== "archived" || value.archive_path !== archivePathTag(entry) || !validDateTime(value.archived_at)) errors.push(`${label}: archive fields do not match indexed location`);
|
|
216
|
+
} else if (value.change_status === "archived" || value.archive_path !== null || value.archived_at !== null || (value.change_status === "completed" && value.phase !== "ready_to_archive")) errors.push(`${label}: active location has invalid terminal fields`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function resolveChangeRelative(changeRoot, locator, label, errors) {
|
|
220
|
+
if (typeof locator !== "string" || !locator || locator.startsWith("/") || locator.includes("\\") || locator.split("/").includes("..")) { errors.push(`${label}: invalid change-relative locator`); return null; }
|
|
221
|
+
const target = resolve(changeRoot, ...locator.split("/"));
|
|
222
|
+
const rel = relative(resolve(changeRoot), target);
|
|
223
|
+
if (rel === ".." || rel.startsWith(".." + sep) || isAbsolute(rel)) { errors.push(`${label}: locator escapes change root`); return null; }
|
|
224
|
+
return target;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function resolveStateRelative(stateRoot, locator, label, errors) {
|
|
228
|
+
if (typeof locator !== "string" || !locator || locator.startsWith("/") || locator.includes("\\") || locator.split("/").includes("..")) { errors.push(`${label}: invalid state-relative locator`); return null; }
|
|
229
|
+
const target = resolve(stateRoot, ...locator.split("/"));
|
|
230
|
+
const rel = relative(resolve(stateRoot), target);
|
|
231
|
+
if (rel === ".." || rel.startsWith(".." + sep) || isAbsolute(rel)) { errors.push(`${label}: locator escapes state root`); return null; }
|
|
232
|
+
return target;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function validateTargetProfile(changeRoot, entry, errors) {
|
|
236
|
+
const locator = "deployment/target-profile.json";
|
|
237
|
+
const path = join(changeRoot, "deployment", "target-profile.json");
|
|
238
|
+
if (!isFile(path)) return null;
|
|
239
|
+
const value = readJson(path, locator, errors);
|
|
240
|
+
if (!value) return null;
|
|
241
|
+
const keys = ["schema_version", "artifact", "scope", "project_id", "change", "created_at", "operation_mode", "environment_class", "deployment_root", "existing_state", "identity_confirmed", "identity_confirmation_evidence", "identity_assertions", "ownership", "differences", "secret_requirements", "readiness", "blockers"];
|
|
242
|
+
if (!exactKeys(value, keys, locator, errors)) return null;
|
|
243
|
+
if (value.schema_version !== 1 || value.artifact !== "ops-target-profile" || value.scope !== entry.scope || value.project_id !== entry.project_id || value.change !== entry.change || !validDateTime(value.created_at)) errors.push(`${locator}: invalid identity or timestamp`);
|
|
244
|
+
if (!["audit", "takeover", "fresh", "release", "upgrade", "rollback"].includes(value.operation_mode) || !["local", "shared-nonprod", "production"].includes(value.environment_class) || !["absent", "present", "unknown"].includes(value.existing_state) || !["blocked", "ready"].includes(value.readiness)) errors.push(`${locator}: invalid mode, environment, existing state or readiness`);
|
|
245
|
+
if (unsafeAbsolutePath(value.deployment_root)) errors.push(`${locator}: deployment_root must be a safe absolute path`);
|
|
246
|
+
if (typeof value.identity_confirmed !== "boolean" || !(value.identity_confirmation_evidence === null || nonEmptyString(value.identity_confirmation_evidence))) errors.push(`${locator}: invalid identity confirmation`);
|
|
247
|
+
if (value.identity_confirmed && !nonEmptyString(value.identity_confirmation_evidence)) errors.push(`${locator}: confirmed identity requires evidence`);
|
|
248
|
+
if (!value.identity_confirmed && value.identity_confirmation_evidence !== null) errors.push(`${locator}: unconfirmed identity cannot claim confirmation evidence`);
|
|
249
|
+
|
|
250
|
+
const assertionMap = new Map();
|
|
251
|
+
if (!Array.isArray(value.identity_assertions) || value.identity_assertions.length === 0) errors.push(`${locator}: identity_assertions must not be empty`);
|
|
252
|
+
for (const assertion of value.identity_assertions ?? []) {
|
|
253
|
+
const label = `${locator} identity ${assertion?.id ?? "unknown"}`;
|
|
254
|
+
if (!exactKeys(assertion, ["id", "provider", "key", "comparison", "expected", "evidence"], label, errors)) continue;
|
|
255
|
+
if (!/^ID[0-9]{3}$/.test(assertion.id ?? "") || assertionMap.has(assertion.id) || !nonEmptyString(assertion.provider) || !nonEmptyString(assertion.key) || !["exact", "ordered-list", "set", "digest"].includes(assertion.comparison) || !validStringList(assertion.evidence, { minimum: 1 })) errors.push(`${label}: invalid or duplicate assertion`);
|
|
256
|
+
const expectedIsString = nonEmptyString(assertion.expected);
|
|
257
|
+
const expectedIsList = validStringList(assertion.expected, { minimum: 1 });
|
|
258
|
+
if (!expectedIsString && !expectedIsList) errors.push(`${label}: expected must be a non-empty string or unique string list`);
|
|
259
|
+
if (assertion.comparison === "exact" && !expectedIsString) errors.push(`${label}: exact comparison requires a string`);
|
|
260
|
+
if (["ordered-list", "set"].includes(assertion.comparison) && !expectedIsList) errors.push(`${label}: ${assertion.comparison} comparison requires a list`);
|
|
261
|
+
if (assertion.comparison === "digest" && (!expectedIsString || !IMMUTABLE_DIGEST.test(assertion.expected))) errors.push(`${label}: digest comparison requires an immutable digest`);
|
|
262
|
+
assertionMap.set(assertion.id, assertion);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const ownership = value.ownership;
|
|
266
|
+
if (!exactKeys(ownership, ["owned_targets", "protected_targets", "unknown_targets"], `${locator} ownership`, errors)) return null;
|
|
267
|
+
for (const field of ["owned_targets", "protected_targets", "unknown_targets"]) if (!validStringList(ownership[field])) errors.push(`${locator}: ownership.${field} must contain unique non-empty strings`);
|
|
268
|
+
const differenceIds = [];
|
|
269
|
+
if (!Array.isArray(value.differences)) errors.push(`${locator}: differences must be an array`);
|
|
270
|
+
for (const difference of value.differences ?? []) {
|
|
271
|
+
const label = `${locator} difference ${difference?.id ?? "unknown"}`;
|
|
272
|
+
if (!exactKeys(difference, ["id", "target", "classification", "evidence"], label, errors)) continue;
|
|
273
|
+
differenceIds.push(difference.id);
|
|
274
|
+
if (!/^DF[0-9]{3}$/.test(difference.id ?? "") || !nonEmptyString(difference.target) || !["expected", "safe-reconcile", "requires-backup", "ownership-conflict", "unknown"].includes(difference.classification) || !validStringList(difference.evidence, { minimum: 1 })) errors.push(`${label}: invalid difference`);
|
|
275
|
+
}
|
|
276
|
+
if (duplicates(differenceIds).length) errors.push(`${locator}: duplicate difference ids`);
|
|
277
|
+
const secretKeys = [];
|
|
278
|
+
if (!Array.isArray(value.secret_requirements)) errors.push(`${locator}: secret_requirements must be an array`);
|
|
279
|
+
for (const requirement of value.secret_requirements ?? []) {
|
|
280
|
+
const label = `${locator} secret requirement ${requirement?.key ?? "unknown"}`;
|
|
281
|
+
if (!exactKeys(requirement, ["key", "source_ref", "presence_required", "version_ref"], label, errors)) continue;
|
|
282
|
+
secretKeys.push(requirement.key);
|
|
283
|
+
if (!nonEmptyString(requirement.key) || !nonEmptyString(requirement.source_ref) || typeof requirement.presence_required !== "boolean" || !(requirement.version_ref === null || nonEmptyString(requirement.version_ref))) errors.push(`${label}: invalid secret metadata`);
|
|
284
|
+
}
|
|
285
|
+
if (duplicates(secretKeys).length) errors.push(`${locator}: duplicate secret requirement keys`);
|
|
286
|
+
if (!validStringList(value.blockers)) errors.push(`${locator}: blockers must contain unique non-empty strings`);
|
|
287
|
+
|
|
288
|
+
const conflict = (value.differences ?? []).some((item) => ["ownership-conflict", "unknown"].includes(item.classification));
|
|
289
|
+
const hasUnknownTargets = (ownership.unknown_targets ?? []).length > 0;
|
|
290
|
+
if (value.operation_mode === "fresh" && (value.existing_state !== "absent" || (ownership.protected_targets ?? []).length || hasUnknownTargets)) errors.push(`${locator}: fresh mode requires absent state and no protected or unknown targets`);
|
|
291
|
+
if (["takeover", "upgrade", "rollback"].includes(value.operation_mode) && value.existing_state !== "present") errors.push(`${locator}: takeover, upgrade and rollback require present existing state`);
|
|
292
|
+
if ((conflict || hasUnknownTargets) && value.readiness !== "blocked") errors.push(`${locator}: ownership conflict or unknown target must block readiness`);
|
|
293
|
+
if (value.existing_state === "unknown" && value.readiness !== "blocked") errors.push(`${locator}: unknown existing state must block readiness`);
|
|
294
|
+
if (value.readiness === "blocked" && !value.blockers.length) errors.push(`${locator}: blocked profile requires blockers`);
|
|
295
|
+
if (value.readiness === "ready" && value.blockers.length) errors.push(`${locator}: Ready profile cannot contain blockers`);
|
|
296
|
+
if (value.readiness === "ready" && value.operation_mode !== "audit" && (!value.identity_confirmed || !value.identity_confirmation_evidence)) errors.push(`${locator}: Ready mutation mode requires confirmed identity`);
|
|
297
|
+
return { value, path, locator, digest: canonicalDigest(value), assertionMap };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function validatePlan(plan, file, entry, profileMeta, errors) {
|
|
301
|
+
const label = `plan/${basename(file)}`;
|
|
302
|
+
const version = plan?.schema_version;
|
|
303
|
+
const baseKeys = ["schema_version", "artifact", "scope", "project_id", "plan_id", "change", "created_at", "supersedes_plan_path", "triggered_by_attempt", "depth", "status", "input_bindings", "scope_definition", "batches", "external_mutations", "global_environment_changes", "operations", "verification", "rollback_strategy", "blockers"];
|
|
304
|
+
const keys = version === 3 ? [...baseKeys, "artifact_requirements", "gates", "data_protection", "retention_policy"] : baseKeys;
|
|
305
|
+
if (![2, 3].includes(version) || !exactKeys(plan, keys, label, errors)) return null;
|
|
306
|
+
const number = /plan-([0-9]{3})\.json$/.exec(file)?.[1];
|
|
307
|
+
if (plan.artifact !== "ops-implementation-plan" || plan.scope !== entry.scope || plan.project_id !== entry.project_id || plan.change !== entry.change || plan.plan_id !== `PLAN-${number}`) errors.push(`${label}: invalid identity`);
|
|
308
|
+
if (!validDateTime(plan.created_at) || !["lite", "standard", "deep"].includes(plan.depth) || !["blocked", "ready"].includes(plan.status)) errors.push(`${label}: invalid metadata`);
|
|
309
|
+
if (!(plan.supersedes_plan_path === null || PLAN_LOCATOR.test(plan.supersedes_plan_path)) || !(plan.triggered_by_attempt === null || ATTEMPT_ID.test(plan.triggered_by_attempt))) errors.push(`${label}: invalid revision lineage`);
|
|
310
|
+
if (plan.triggered_by_attempt !== null && plan.supersedes_plan_path === null) errors.push(`${label}: an attempt-triggered revision must bind the superseded plan`);
|
|
311
|
+
|
|
312
|
+
const bindings = plan.input_bindings;
|
|
313
|
+
const bindingKeys = version === 3
|
|
314
|
+
? ["request_path", "snapshot_path", "snapshot_digest", "deployment_model_path", "deployment_model_digest", "target_profile_path", "target_profile_digest", "source_revision", "target_fingerprint"]
|
|
315
|
+
: ["request_path", "snapshot_path", "snapshot_digest", "deployment_model_path", "deployment_model_digest", "source_revision", "target_fingerprint"];
|
|
316
|
+
if (!exactKeys(bindings, bindingKeys, `${label} input_bindings`, errors)) return null;
|
|
317
|
+
if (!nonEmptyString(bindings.request_path) || !nonEmptyString(bindings.snapshot_path) || !DIGEST.test(bindings.snapshot_digest ?? "") || !nonEmptyString(bindings.deployment_model_path) || !DIGEST.test(bindings.deployment_model_digest ?? "") || !DIGEST.test(bindings.target_fingerprint ?? "") || !nonEmptyString(bindings.source_revision)) errors.push(`${label}: invalid bindings`);
|
|
318
|
+
if (version === 3 && (!TARGET_PROFILE_LOCATOR.test(bindings.target_profile_path ?? "") || !DIGEST.test(bindings.target_profile_digest ?? ""))) errors.push(`${label}: invalid target profile binding`);
|
|
319
|
+
|
|
320
|
+
const scope = plan.scope_definition;
|
|
321
|
+
if (!exactKeys(scope, ["source_root", "deployment_root", "read_roots", "write_roots", "forbidden_roots"], `${label} scope_definition`, errors)) return null;
|
|
322
|
+
if (!nonEmptyString(scope.source_root) || !isAbsolute(scope.source_root) || scope.source_root.includes("$") || scope.source_root.split(/[\\/]/).includes("..")) errors.push(`${label}: source_root must be a resolved absolute path`);
|
|
323
|
+
if (unsafeAbsolutePath(scope.deployment_root)) errors.push(`${label}: deployment_root must be a safe absolute path`);
|
|
324
|
+
for (const field of ["read_roots", "write_roots", "forbidden_roots"]) if (!Array.isArray(scope[field]) || scope[field].some((path) => !nonEmptyString(path) || !isAbsolute(path) || path.includes("$") || path.split(/[\\/]/).includes("..")) || duplicates(scope[field] ?? []).length) errors.push(`${label}: ${field} must contain unique resolved absolute paths`);
|
|
325
|
+
if (!Array.isArray(scope.write_roots) || !scope.write_roots.length) errors.push(`${label}: write_roots must not be empty`);
|
|
326
|
+
for (const path of scope.write_roots ?? []) {
|
|
327
|
+
if (!pathInsideOrEqual(scope.deployment_root, path)) errors.push(`${label}: write root escapes deployment root`);
|
|
328
|
+
if ((scope.forbidden_roots ?? []).some((forbidden) => pathsOverlap(path, forbidden))) errors.push(`${label}: write root overlaps forbidden root ${forbidden}`);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const collectionNames = ["batches", "operations", "external_mutations", "global_environment_changes", "verification", "blockers"];
|
|
332
|
+
if (version === 3) collectionNames.push("artifact_requirements", "gates", "data_protection");
|
|
333
|
+
if (collectionNames.some((name) => !Array.isArray(plan[name]))) { errors.push(`${label}: plan collections are incomplete`); return null; }
|
|
334
|
+
if (!validStringList(plan.blockers)) errors.push(`${label}: blockers must contain unique non-empty strings`);
|
|
335
|
+
const batchIds = plan.batches.map((item) => item?.id); const operationIds = plan.operations.map((item) => item?.id); const externalIds = plan.external_mutations.map((item) => item?.id);
|
|
336
|
+
if (duplicates(batchIds).length || batchIds.some((id) => !BATCH_ID.test(id ?? "")) || duplicates(operationIds).length || operationIds.some((id) => !OPERATION_ID.test(id ?? "")) || duplicates(externalIds).length || externalIds.some((id) => !/^EM[0-9]{3}$/.test(id ?? ""))) errors.push(`${label}: invalid or duplicate ids`);
|
|
337
|
+
const batchSet = new Set(batchIds); const operationSet = new Set(operationIds); const externalSet = new Set(externalIds); const dependencies = new Map(); const batchMap = new Map(); const projected = [];
|
|
338
|
+
for (const batch of plan.batches) {
|
|
339
|
+
const batchKeys = version === 3 ? ["id", "title", "risk", "requires_confirmation", "depends_on", "operation_ids", "gate_ids"] : ["id", "title", "risk", "requires_confirmation", "depends_on", "operation_ids"];
|
|
340
|
+
if (!exactKeys(batch, batchKeys, `${label} batch ${batch?.id ?? "unknown"}`, errors)) continue;
|
|
341
|
+
if (batch.requires_confirmation !== true || !nonEmptyString(batch.title) || !["low", "medium", "high", "critical"].includes(batch.risk) || !Array.isArray(batch.depends_on) || duplicates(batch.depends_on).length || !Array.isArray(batch.operation_ids) || !batch.operation_ids.length || duplicates(batch.operation_ids).length || (version === 3 && (!Array.isArray(batch.gate_ids) || duplicates(batch.gate_ids).length))) errors.push(`${label}: incomplete batch ${batch.id}`);
|
|
342
|
+
dependencies.set(batch.id, batch.depends_on ?? []); batchMap.set(batch.id, batch);
|
|
343
|
+
for (const dep of batch.depends_on ?? []) if (!batchSet.has(dep) || dep === batch.id) errors.push(`${label}: invalid batch dependency ${dep}`);
|
|
344
|
+
for (const id of batch.operation_ids ?? []) { projected.push(id); if (!operationSet.has(id)) errors.push(`${label}: missing operation ${id}`); }
|
|
345
|
+
}
|
|
346
|
+
if (duplicates(projected).length || !sameMembers(new Set(projected), operationSet)) errors.push(`${label}: batches must project each operation once`);
|
|
347
|
+
|
|
348
|
+
const operationMap = new Map();
|
|
349
|
+
for (const operation of plan.operations) {
|
|
350
|
+
const operationLabel = `${label} operation ${operation?.id ?? "unknown"}`;
|
|
351
|
+
const operationKeys = ["id", "batch_id", "kind", "target", "working_directory", "preconditions", "write_set", "external_mutation_id", "preview", "apply", "postconditions", "rollback", "risk", "required_privilege", "evidence_path"];
|
|
352
|
+
if (!exactKeys(operation, operationKeys, operationLabel, errors)) continue;
|
|
353
|
+
operationMap.set(operation.id, operation);
|
|
354
|
+
if (!batchSet.has(operation.batch_id) || !batchMap.get(operation.batch_id)?.operation_ids?.includes(operation.id)) errors.push(`${label}: operation ${operation.id} batch mismatch`);
|
|
355
|
+
if (!(operation.external_mutation_id === null || externalSet.has(operation.external_mutation_id))) errors.push(`${label}: operation ${operation.id} missing external mutation`);
|
|
356
|
+
if (!validStringList(operation.preconditions, { minimum: 1 }) || !validStringList(operation.postconditions, { minimum: 1 }) || !validStringList(operation.write_set) || !isObject(operation.apply) || !nonEmptyString(operation.apply.command) || !nonEmptyString(operation.apply.shell)) errors.push(`${label}: operation ${operation.id} is incomplete`);
|
|
357
|
+
const cwd = operation.working_directory;
|
|
358
|
+
if (!nonEmptyString(cwd) || !isAbsolute(cwd) || cwd.includes("$") || cwd.split(/[\\/]/).includes("..") || ![...(scope.read_roots ?? []), ...(scope.write_roots ?? [])].some((root) => pathInsideOrEqual(root, cwd)) || (scope.forbidden_roots ?? []).some((root) => pathInsideOrEqual(root, cwd))) errors.push(`${label}: operation ${operation.id} cwd must be a resolved path inside read/write roots and outside forbidden roots`);
|
|
359
|
+
for (const path of operation.write_set ?? []) if (!nonEmptyString(path) || !isAbsolute(path) || path.includes("$") || path.split(/[\\/]/).includes("..") || !(scope.write_roots ?? []).some((root) => pathInsideOrEqual(root, path)) || (scope.forbidden_roots ?? []).some((root) => pathsOverlap(root, path))) errors.push(`${label}: operation ${operation.id} write escapes roots or overlaps a forbidden root`);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
for (const mutation of plan.external_mutations) {
|
|
363
|
+
const mutationLabel = `${label} external mutation ${mutation?.id ?? "unknown"}`;
|
|
364
|
+
if (!exactKeys(mutation, ["id", "provider", "target", "current_state", "desired_state", "privilege", "blast_radius", "preview", "rollback", "batch_id"], mutationLabel, errors)) continue;
|
|
365
|
+
if (!batchSet.has(mutation.batch_id) || !nonEmptyString(mutation.provider) || !nonEmptyString(mutation.target)) errors.push(`${mutationLabel}: invalid mutation`);
|
|
366
|
+
}
|
|
367
|
+
const environmentKeys = [];
|
|
368
|
+
for (const environment of plan.global_environment_changes) {
|
|
369
|
+
const environmentLabel = `${label} global environment ${environment?.key ?? "unknown"}`;
|
|
370
|
+
if (!exactKeys(environment, ["key", "target_scope", "value_source_ref", "impact", "rollback", "batch_id"], environmentLabel, errors)) continue;
|
|
371
|
+
environmentKeys.push(`${environment.key}\0${environment.target_scope}`);
|
|
372
|
+
if (!batchSet.has(environment.batch_id) || !nonEmptyString(environment.key) || !nonEmptyString(environment.target_scope) || !nonEmptyString(environment.value_source_ref) || !nonEmptyString(environment.impact) || !nonEmptyString(environment.rollback)) errors.push(`${environmentLabel}: invalid global environment change`);
|
|
373
|
+
}
|
|
374
|
+
if (duplicates(environmentKeys).length) errors.push(`${label}: duplicate global environment target/key`);
|
|
375
|
+
|
|
376
|
+
const verificationMap = new Map();
|
|
377
|
+
for (const verification of plan.verification) {
|
|
378
|
+
const verificationLabel = `${label} verification ${verification?.id ?? "unknown"}`;
|
|
379
|
+
if (version === 2) {
|
|
380
|
+
if (!exactKeys(verification, ["id", "kind", "target", "success", "required"], verificationLabel, errors)) continue;
|
|
381
|
+
if (!nonEmptyString(verification.id) || verificationMap.has(verification.id) || !nonEmptyString(verification.kind) || !nonEmptyString(verification.target) || !nonEmptyString(verification.success) || typeof verification.required !== "boolean") errors.push(`${verificationLabel}: invalid verification`);
|
|
382
|
+
} else {
|
|
383
|
+
if (!exactKeys(verification, ["id", "kind", "target", "required", "expected", "stability", "convergence_group"], verificationLabel, errors)) continue;
|
|
384
|
+
if (!VERIFICATION_ID.test(verification.id ?? "") || verificationMap.has(verification.id) || !["http", "tcp", "command", "service", "artifact", "data", "custom"].includes(verification.kind) || !nonEmptyString(verification.target) || typeof verification.required !== "boolean") errors.push(`${verificationLabel}: invalid verification`);
|
|
385
|
+
const expected = verification.expected;
|
|
386
|
+
if (!exactKeys(expected, ["description", "http_statuses", "business_codes", "authenticated"], `${verificationLabel} expected`, errors)) continue;
|
|
387
|
+
if (!nonEmptyString(expected.description) || !Array.isArray(expected.http_statuses) || duplicates(expected.http_statuses).length || expected.http_statuses.some((status) => !Number.isInteger(status) || status < 100 || status > 599) || !Array.isArray(expected.business_codes) || duplicates(expected.business_codes).length || !(expected.authenticated === null || typeof expected.authenticated === "boolean")) errors.push(`${verificationLabel}: invalid expected result`);
|
|
388
|
+
if (verification.kind === "http" && (!expected.http_statuses.length || typeof expected.authenticated !== "boolean")) errors.push(`${verificationLabel}: HTTP verification requires statuses and explicit authentication expectation`);
|
|
389
|
+
if (verification.stability !== null) {
|
|
390
|
+
if (!exactKeys(verification.stability, ["interval_seconds", "timeout_seconds", "required_consecutive_successes"], `${verificationLabel} stability`, errors) || !(verification.stability.interval_seconds > 0) || !(verification.stability.timeout_seconds > 0) || !Number.isInteger(verification.stability.required_consecutive_successes) || verification.stability.required_consecutive_successes < 1 || verification.stability.timeout_seconds < verification.stability.interval_seconds * verification.stability.required_consecutive_successes) errors.push(`${verificationLabel}: invalid stability window`);
|
|
391
|
+
}
|
|
392
|
+
if (!(verification.convergence_group === null || nonEmptyString(verification.convergence_group))) errors.push(`${verificationLabel}: invalid convergence group`);
|
|
393
|
+
}
|
|
394
|
+
verificationMap.set(verification.id, verification);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const gateMap = new Map(); const artifactMap = new Map(); const protectionMap = new Map();
|
|
398
|
+
if (version === 3) {
|
|
399
|
+
for (const artifact of plan.artifact_requirements) {
|
|
400
|
+
const artifactLabel = `${label} artifact ${artifact?.id ?? "unknown"}`;
|
|
401
|
+
if (!exactKeys(artifact, ["id", "kind", "source_ref", "immutable_digest", "staging_ref", "activation_target", "previous_ref", "required"], artifactLabel, errors)) continue;
|
|
402
|
+
if (!/^AR[0-9]{3}$/.test(artifact.id ?? "") || artifactMap.has(artifact.id) || !["file", "archive", "container-image", "static-bundle", "other"].includes(artifact.kind) || !nonEmptyString(artifact.source_ref) || !IMMUTABLE_DIGEST.test(artifact.immutable_digest ?? "") || !(artifact.staging_ref === null || nonEmptyString(artifact.staging_ref)) || !nonEmptyString(artifact.activation_target) || !(artifact.previous_ref === null || nonEmptyString(artifact.previous_ref)) || typeof artifact.required !== "boolean") errors.push(`${artifactLabel}: invalid artifact requirement`);
|
|
403
|
+
if (["file", "archive", "static-bundle"].includes(artifact.kind)) for (const [field, locator] of [["staging_ref", artifact.staging_ref], ["activation_target", artifact.activation_target], ["previous_ref", artifact.previous_ref]]) if (locator !== null && (!isAbsolute(locator) || locator.includes("$") || locator.split(/[\\/]/).includes("..") || !pathInsideOrEqual(scope.deployment_root, locator))) errors.push(`${artifactLabel}: ${field} must be a resolved absolute path under deployment_root`);
|
|
404
|
+
artifactMap.set(artifact.id, artifact);
|
|
405
|
+
}
|
|
406
|
+
for (const gate of plan.gates) {
|
|
407
|
+
const gateLabel = `${label} gate ${gate?.id ?? "unknown"}`;
|
|
408
|
+
if (!exactKeys(gate, ["id", "title", "depends_on", "after_batches", "verification_ids", "required"], gateLabel, errors)) continue;
|
|
409
|
+
if (!GATE_ID.test(gate.id ?? "") || gateMap.has(gate.id) || !nonEmptyString(gate.title) || !Array.isArray(gate.depends_on) || duplicates(gate.depends_on).length || !Array.isArray(gate.after_batches) || !gate.after_batches.length || duplicates(gate.after_batches).length || !Array.isArray(gate.verification_ids) || !gate.verification_ids.length || duplicates(gate.verification_ids).length || typeof gate.required !== "boolean") errors.push(`${gateLabel}: invalid gate`);
|
|
410
|
+
gateMap.set(gate.id, gate);
|
|
411
|
+
}
|
|
412
|
+
for (const batch of plan.batches) for (const gateId of batch.gate_ids ?? []) if (!gateMap.has(gateId)) errors.push(`${label}: batch ${batch.id} references missing gate ${gateId}`);
|
|
413
|
+
const verificationReferences = [];
|
|
414
|
+
for (const gate of plan.gates) {
|
|
415
|
+
for (const dependency of gate.depends_on ?? []) if (!gateMap.has(dependency) || dependency === gate.id) errors.push(`${label}: gate ${gate.id} has invalid dependency ${dependency}`);
|
|
416
|
+
for (const batchId of gate.after_batches ?? []) if (!batchSet.has(batchId)) errors.push(`${label}: gate ${gate.id} references missing prior batch ${batchId}`);
|
|
417
|
+
for (const verificationId of gate.verification_ids ?? []) { verificationReferences.push(verificationId); if (!verificationMap.has(verificationId)) errors.push(`${label}: gate ${gate.id} references missing verification ${verificationId}`); }
|
|
418
|
+
}
|
|
419
|
+
for (const verification of plan.verification) if (verification.required && verificationReferences.filter((id) => id === verification.id).length !== 1) errors.push(`${label}: required verification ${verification.id} must be referenced by exactly one gate`);
|
|
420
|
+
|
|
421
|
+
const graph = new Map();
|
|
422
|
+
for (const id of batchSet) graph.set(`B:${id}`, []);
|
|
423
|
+
for (const id of gateMap.keys()) graph.set(`G:${id}`, []);
|
|
424
|
+
for (const batch of plan.batches) graph.get(`B:${batch.id}`)?.push(...(batch.depends_on ?? []).map((id) => `B:${id}`), ...(batch.gate_ids ?? []).map((id) => `G:${id}`));
|
|
425
|
+
for (const gate of plan.gates) graph.get(`G:${gate.id}`)?.push(...(gate.depends_on ?? []).map((id) => `G:${id}`), ...(gate.after_batches ?? []).map((id) => `B:${id}`));
|
|
426
|
+
const visiting = new Set(); const visited = new Set();
|
|
427
|
+
function visit(node) { if (visited.has(node)) return; if (visiting.has(node)) { errors.push(`${label}: combined batch/gate dependency cycle includes ${node}`); return; } visiting.add(node); for (const dependency of graph.get(node) ?? []) visit(dependency); visiting.delete(node); visited.add(node); }
|
|
428
|
+
for (const node of graph.keys()) visit(node);
|
|
429
|
+
|
|
430
|
+
for (const protection of plan.data_protection) {
|
|
431
|
+
const protectionLabel = `${label} data protection ${protection?.id ?? "unknown"}`;
|
|
432
|
+
if (!exactKeys(protection, ["id", "resource", "operation_ids", "strategy", "backup", "waiver"], protectionLabel, errors)) continue;
|
|
433
|
+
if (!/^DP[0-9]{3}$/.test(protection.id ?? "") || protectionMap.has(protection.id) || !nonEmptyString(protection.resource) || !Array.isArray(protection.operation_ids) || duplicates(protection.operation_ids).length || protection.operation_ids.some((id) => !operationSet.has(id)) || !["verified-backup", "waiver", "not-required"].includes(protection.strategy)) errors.push(`${protectionLabel}: invalid protection entry`);
|
|
434
|
+
if (protection.strategy === "verified-backup") {
|
|
435
|
+
if (!exactKeys(protection.backup, ["evidence_path", "digest", "readability_verified", "restore_ref", "restore_verified"], `${protectionLabel} backup`, errors) || !nonEmptyString(protection.backup?.evidence_path) || !DIGEST.test(protection.backup?.digest ?? "") || protection.backup?.readability_verified !== true || !nonEmptyString(protection.backup?.restore_ref) || typeof protection.backup?.restore_verified !== "boolean" || protection.waiver !== null) errors.push(`${protectionLabel}: verified backup evidence is incomplete`);
|
|
436
|
+
} else if (protection.strategy === "waiver") {
|
|
437
|
+
if (!exactKeys(protection.waiver, ["decision_ref", "exact_scope", "object_identity_verified", "conflicts_absent", "recovery_mode"], `${protectionLabel} waiver`, errors) || protection.backup !== null || !nonEmptyString(protection.waiver?.decision_ref) || !nonEmptyString(protection.waiver?.exact_scope) || protection.waiver?.object_identity_verified !== true || protection.waiver?.conflicts_absent !== true || protection.waiver?.recovery_mode !== "forward-only") errors.push(`${protectionLabel}: waiver preflight is incomplete`);
|
|
438
|
+
if (profileMeta?.value.environment_class !== "local") errors.push(`${protectionLabel}: waiver is allowed only for local environments`);
|
|
439
|
+
} else if (protection.backup !== null || protection.waiver !== null) errors.push(`${protectionLabel}: not-required cannot carry backup or waiver`);
|
|
440
|
+
protectionMap.set(protection.id, protection);
|
|
441
|
+
}
|
|
442
|
+
for (const operation of plan.operations.filter((item) => item.kind === "database")) {
|
|
443
|
+
const matches = plan.data_protection.filter((item) => item.operation_ids?.includes(operation.id));
|
|
444
|
+
if (matches.length !== 1 || matches[0]?.strategy === "not-required") errors.push(`${label}: database operation ${operation.id} requires exactly one backup or waiver policy`);
|
|
445
|
+
}
|
|
446
|
+
if (!exactKeys(plan.retention_policy, ["retain_failed_candidates", "retain_previous_release", "cleanup_requires_separate_approval"], `${label} retention_policy`, errors) || plan.retention_policy?.retain_failed_candidates !== true || plan.retention_policy?.retain_previous_release !== true || plan.retention_policy?.cleanup_requires_separate_approval !== true) errors.push(`${label}: retention policy must preserve failure and rollback assets`);
|
|
447
|
+
if (profileMeta?.value.environment_class === "production" && plan.data_protection.some((item) => item.strategy === "waiver")) errors.push(`${label}: production data protection cannot use waiver`);
|
|
448
|
+
if (["release", "upgrade"].includes(profileMeta?.value.operation_mode) && ![...artifactMap.values()].some((item) => item.required)) errors.push(`${label}: release and upgrade require at least one immutable artifact`);
|
|
449
|
+
if (profileMeta?.value.operation_mode === "audit" && plan.operations.length) errors.push(`${label}: audit mode cannot contain mutation operations`);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if (plan.status === "ready" && (plan.blockers.length || !plan.batches.length || !plan.operations.length || (version === 3 && !plan.gates.length))) errors.push(`${label}: ready plan requires operations, gates and no blockers`);
|
|
453
|
+
if (plan.status === "blocked" && !plan.blockers.length) errors.push(`${label}: blocked plan requires blockers`);
|
|
454
|
+
return { version, digest: canonicalDigest(plan), batchSet, batchMap, dependencies, operationMap, bindings, globalEnvironment: plan.global_environment_changes, verificationMap, gateMap, artifactMap, protectionMap };
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function validateApproval(approval, file, entry, planMeta, status, preExecute, errors) {
|
|
458
|
+
const label = `plan/${basename(file)}`;
|
|
459
|
+
const keys = ["schema_version", "artifact", "scope", "project_id", "approval_id", "change", "plan_path", "plan_digest", "source_revision", "target_fingerprint", "approved_batches", "excluded_batches", "confirmed_global_environment_keys", "conditions", "decision", "decision_summary", "decided_at", "expires_at"];
|
|
460
|
+
if (!exactKeys(approval, keys, label, errors)) return;
|
|
461
|
+
const number = /approval-([0-9]{3})\.json$/.exec(file)?.[1];
|
|
462
|
+
if (approval.schema_version !== 2 || approval.artifact !== "ops-plan-approval" || approval.scope !== entry.scope || approval.project_id !== entry.project_id || approval.change !== entry.change || approval.approval_id !== `APPROVAL-${number}` || approval.decision !== "approved") errors.push(`${label}: invalid identity`);
|
|
463
|
+
if (approval.plan_path !== status.plan_path || approval.plan_digest !== planMeta.digest || approval.plan_digest !== status.plan_digest || approval.source_revision !== planMeta.bindings.source_revision || approval.source_revision !== status.source_revision || approval.target_fingerprint !== planMeta.bindings.target_fingerprint || approval.target_fingerprint !== status.target_fingerprint) errors.push(`${label}: plan/source/target binding mismatch`);
|
|
464
|
+
if (!Array.isArray(approval.approved_batches) || !approval.approved_batches.length || duplicates(approval.approved_batches).length || approval.approved_batches.some((id) => !planMeta.batchSet.has(id))) errors.push(`${label}: invalid approved batches`);
|
|
465
|
+
if (!Array.isArray(approval.excluded_batches) || duplicates(approval.excluded_batches ?? []).length || approval.excluded_batches.some((id) => !planMeta.batchSet.has(id) || approval.approved_batches.includes(id)) || !sameMembers(new Set([...(approval.approved_batches ?? []), ...(approval.excluded_batches ?? [])]), planMeta.batchSet) || !sameMembers(new Set(status.approved_batches), new Set(approval.approved_batches))) errors.push(`${label}: excluded/status batches mismatch`);
|
|
466
|
+
for (const id of approval.approved_batches ?? []) for (const dep of planMeta.dependencies.get(id) ?? []) if (!approval.approved_batches.includes(dep)) errors.push(`${label}: approved batch ${id} lacks dependency ${dep}`);
|
|
467
|
+
const expectedEnv = new Set(planMeta.globalEnvironment.filter((item) => approval.approved_batches?.includes(item.batch_id)).map((item) => item.key));
|
|
468
|
+
if (!validStringList(approval.confirmed_global_environment_keys ?? []) || !sameMembers(expectedEnv, new Set(approval.confirmed_global_environment_keys ?? []))) errors.push(`${label}: confirmed global environment keys mismatch`);
|
|
469
|
+
if (!validStringList(approval.conditions) || !nonEmptyString(approval.decision_summary) || !validDateTime(approval.decided_at) || !validDateTime(approval.expires_at) || Date.parse(approval.expires_at) <= Date.parse(approval.decided_at) || (preExecute && Date.parse(approval.expires_at) <= Date.now())) errors.push(`${label}: invalid or expired approval window`);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function validatePlanAndApproval(changeRoot, entry, status, profileMeta, preExecute, errors) {
|
|
473
|
+
if (!status.plan_path) { if (preExecute) errors.push(`${entryKey(entry)}: pre-execute requires a plan`); return null; }
|
|
474
|
+
const planFile = resolveChangeRelative(changeRoot, status.plan_path, "plan_path", errors);
|
|
475
|
+
if (!planFile || !isFile(planFile)) { errors.push(`${entryKey(entry)}: indexed plan is missing`); return null; }
|
|
476
|
+
const plan = readJson(planFile, status.plan_path, errors); if (!plan) return null;
|
|
477
|
+
const meta = validatePlan(plan, planFile, entry, profileMeta, errors); if (!meta) return null;
|
|
478
|
+
if (!isFile(planFile.replace(/\.json$/, ".md"))) errors.push(`${entryKey(entry)}: plan Markdown projection is missing`);
|
|
479
|
+
if (plan.supersedes_plan_path && !isFile(resolveChangeRelative(changeRoot, plan.supersedes_plan_path, "superseded plan", errors))) errors.push(`${entryKey(entry)}: superseded plan is missing`);
|
|
480
|
+
if (plan.triggered_by_attempt && !isDirectory(join(changeRoot, "execution", "attempts", plan.triggered_by_attempt))) errors.push(`${entryKey(entry)}: triggering attempt is missing`);
|
|
481
|
+
for (const [pathKey, digestKey] of [["snapshot_path", "snapshot_digest"], ["deployment_model_path", "deployment_model_digest"], ...(meta.version === 3 ? [["target_profile_path", "target_profile_digest"]] : [])]) {
|
|
482
|
+
const file = resolveChangeRelative(changeRoot, meta.bindings[pathKey], pathKey, errors);
|
|
483
|
+
const value = file && isFile(file) ? readJson(file, meta.bindings[pathKey], errors) : null;
|
|
484
|
+
if (!value) errors.push(`${entryKey(entry)}: ${pathKey} binding is missing`); else if (canonicalDigest(value) !== meta.bindings[digestKey]) errors.push(`${entryKey(entry)}: ${pathKey} binding is stale`);
|
|
485
|
+
}
|
|
486
|
+
const requestFile = resolveChangeRelative(changeRoot, meta.bindings.request_path, "request_path", errors);
|
|
487
|
+
if (!requestFile || !isFile(requestFile)) errors.push(`${entryKey(entry)}: request_path binding is missing`);
|
|
488
|
+
if (meta.version === 3) {
|
|
489
|
+
if (!profileMeta || profileMeta.locator !== meta.bindings.target_profile_path || profileMeta.digest !== meta.bindings.target_profile_digest) errors.push(`${entryKey(entry)}: plan target profile binding mismatch`);
|
|
490
|
+
if (profileMeta && plan.scope_definition.deployment_root !== profileMeta.value.deployment_root) errors.push(`${entryKey(entry)}: plan deployment root differs from target profile`);
|
|
491
|
+
if (preExecute && profileMeta?.value.readiness !== "ready") errors.push(`${entryKey(entry)}: pre-execute requires a Ready target profile`);
|
|
492
|
+
} else if (preExecute) errors.push(`${entryKey(entry)}: pre-execute rejects legacy plan schema v2; create a v3 plan`);
|
|
493
|
+
if (meta.digest !== status.plan_digest || status.source_revision !== meta.bindings.source_revision || status.target_fingerprint !== meta.bindings.target_fingerprint) errors.push(`${entryKey(entry)}: status plan/source/target projection mismatch`);
|
|
494
|
+
if (status.approval_status !== "approved") { if (preExecute) errors.push(`${entryKey(entry)}: pre-execute requires approval`); return { plan, meta, approval: null }; }
|
|
495
|
+
if (plan.status !== "ready") errors.push(`${entryKey(entry)}: approved state requires Ready plan`);
|
|
496
|
+
const approvalFile = resolveChangeRelative(changeRoot, status.approval_path, "approval_path", errors);
|
|
497
|
+
if (!approvalFile || !isFile(approvalFile)) { errors.push(`${entryKey(entry)}: indexed approval is missing`); return { plan, meta, approval: null }; }
|
|
498
|
+
const approval = readJson(approvalFile, status.approval_path, errors);
|
|
499
|
+
if (approval) validateApproval(approval, approvalFile, entry, meta, status, preExecute, errors);
|
|
500
|
+
return { plan, meta, approval };
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function validateDomainArtifacts(changeRoot, entry, status, errors) {
|
|
504
|
+
const profileMeta = validateTargetProfile(changeRoot, entry, errors);
|
|
505
|
+
const snapshotsRoot = join(changeRoot, "inventory", "snapshots");
|
|
506
|
+
for (const name of isDirectory(snapshotsRoot) ? readdirSync(snapshotsRoot).filter((item) => item.endsWith(".json")) : []) {
|
|
507
|
+
const value = readJson(join(snapshotsRoot, name), `inventory/snapshots/${name}`, errors);
|
|
508
|
+
if (value && (value.schema_version !== 2 || value.artifact !== "ops-inventory-snapshot" || value.scope !== entry.scope || value.project_id !== entry.project_id || value.change !== entry.change || value.snapshot_id !== name.slice(0, -5) || !DIGEST.test(value.target_fingerprint ?? ""))) errors.push(`inventory/snapshots/${name}: invalid identity`);
|
|
509
|
+
}
|
|
510
|
+
const modelPath = join(changeRoot, "deployment", "deployment-model.json");
|
|
511
|
+
if (isFile(modelPath)) {
|
|
512
|
+
const value = readJson(modelPath, "deployment/deployment-model.json", errors);
|
|
513
|
+
if (value && (value.schema_version !== 2 || value.artifact !== "ops-deployment-model" || value.scope !== entry.scope || value.project_id !== entry.project_id || value.change !== entry.change || !["blocked", "ready"].includes(value.readiness))) errors.push("deployment/deployment-model.json: invalid identity/readiness");
|
|
514
|
+
for (const item of value?.configuration ?? []) if (Object.hasOwn(item, "value")) errors.push("deployment/deployment-model.json: configuration must not persist values");
|
|
515
|
+
}
|
|
516
|
+
void status;
|
|
517
|
+
return { profileMeta };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function validateAttemptPlanBinding(changeRoot, entry, attempt, profileMeta, errors) {
|
|
521
|
+
if (!attempt.plan_path) return null;
|
|
522
|
+
const planFile = resolveChangeRelative(changeRoot, attempt.plan_path, `${attempt.attempt_id} plan_path`, errors);
|
|
523
|
+
if (!planFile || !isFile(planFile)) { errors.push(`${attempt.attempt_id}: bound plan is missing`); return null; }
|
|
524
|
+
const plan = readJson(planFile, `${attempt.attempt_id} plan`, errors); if (!plan) return null;
|
|
525
|
+
const meta = validatePlan(plan, planFile, entry, profileMeta, errors); if (!meta) return null;
|
|
526
|
+
if (attempt.plan_digest !== meta.digest) errors.push(`${attempt.attempt_id}: plan digest binding mismatch`);
|
|
527
|
+
const approvalFile = resolveChangeRelative(changeRoot, attempt.approval_path, `${attempt.attempt_id} approval_path`, errors);
|
|
528
|
+
if (!approvalFile || !isFile(approvalFile)) { errors.push(`${attempt.attempt_id}: bound approval is missing`); return { plan, meta, approval: null }; }
|
|
529
|
+
const approval = readJson(approvalFile, `${attempt.attempt_id} approval`, errors);
|
|
530
|
+
if (approval) {
|
|
531
|
+
const projection = { plan_path: attempt.plan_path, plan_digest: meta.digest, source_revision: meta.bindings.source_revision, target_fingerprint: meta.bindings.target_fingerprint, approved_batches: approval.approved_batches };
|
|
532
|
+
validateApproval(approval, approvalFile, entry, meta, projection, false, errors);
|
|
533
|
+
for (const batch of attempt.executed_batches ?? []) if (!approval.approved_batches?.includes(batch)) errors.push(`${attempt.attempt_id}: executed unapproved batch ${batch}`);
|
|
534
|
+
}
|
|
535
|
+
return { plan, meta, approval };
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function readJournal(path, attempt, planMeta, errors) {
|
|
539
|
+
const label = `${attempt.attempt_id}/journal.jsonl`;
|
|
540
|
+
let text;
|
|
541
|
+
try { text = readFileSync(path, "utf8"); }
|
|
542
|
+
catch (error) { errors.push(`${label}: cannot read (${error.message})`); return null; }
|
|
543
|
+
const trimmed = text.replace(/\s+$/u, "");
|
|
544
|
+
if (!trimmed) { errors.push(`${label}: journal is empty`); return null; }
|
|
545
|
+
const lines = trimmed.split(/\r?\n/u); const events = [];
|
|
546
|
+
for (const [index, line] of lines.entries()) {
|
|
547
|
+
if (!line.trim()) { errors.push(`${label}:${index + 1}: blank JSONL record`); continue; }
|
|
548
|
+
let event;
|
|
549
|
+
try { event = JSON.parse(line); }
|
|
550
|
+
catch (error) { errors.push(`${label}:${index + 1}: invalid JSON (${error.message})`); continue; }
|
|
551
|
+
const eventLabel = `${label}:${index + 1}`;
|
|
552
|
+
const keys = ["schema_version", "artifact", "sequence", "at", "event", "batch_id", "gate_id", "operation_id", "result", "summary", "output_digest", "evidence"];
|
|
553
|
+
if (!exactKeys(event, keys, eventLabel, errors)) continue;
|
|
554
|
+
if (event.schema_version !== 1 || event.artifact !== "ops-journal-event" || event.sequence !== index + 1 || !validDateTime(event.at) || !["attempt-start", "gate-result", "operation-intent", "operation-result", "postcondition-result", "attempt-end"].includes(event.event) || !["pending", "passed", "failed", "blocked", "skipped"].includes(event.result) || !nonEmptyString(event.summary) || !(event.output_digest === null || DIGEST.test(event.output_digest)) || !validStringList(event.evidence)) errors.push(`${eventLabel}: invalid journal event`);
|
|
555
|
+
const isAttempt = ["attempt-start", "attempt-end"].includes(event.event);
|
|
556
|
+
const isGate = event.event === "gate-result";
|
|
557
|
+
const isOperation = ["operation-intent", "operation-result", "postcondition-result"].includes(event.event);
|
|
558
|
+
if (isAttempt && (event.batch_id !== null || event.gate_id !== null || event.operation_id !== null)) errors.push(`${eventLabel}: attempt boundary cannot reference batch, gate or operation`);
|
|
559
|
+
if (isGate && (!(event.batch_id === null || BATCH_ID.test(event.batch_id)) || !GATE_ID.test(event.gate_id ?? "") || event.operation_id !== null)) errors.push(`${eventLabel}: gate-result has invalid references`);
|
|
560
|
+
if (isOperation && (!BATCH_ID.test(event.batch_id ?? "") || event.gate_id !== null || !OPERATION_ID.test(event.operation_id ?? ""))) errors.push(`${eventLabel}: operation event has invalid references`);
|
|
561
|
+
if (event.event === "attempt-start" && event.result !== "pending") errors.push(`${eventLabel}: attempt-start must be pending`);
|
|
562
|
+
if (event.event === "operation-intent" && event.result !== "pending") errors.push(`${eventLabel}: operation-intent must be pending`);
|
|
563
|
+
if (["gate-result", "operation-result", "postcondition-result", "attempt-end"].includes(event.event) && event.result === "pending") errors.push(`${eventLabel}: result event cannot remain pending`);
|
|
564
|
+
if (containsSecretMaterial(event.summary) || (event.evidence ?? []).some(containsSecretMaterial)) errors.push(`${eventLabel}: summary/evidence contains secret material`);
|
|
565
|
+
events.push(event);
|
|
566
|
+
}
|
|
567
|
+
if (!events.length) return null;
|
|
568
|
+
if (events[0].event !== "attempt-start") errors.push(`${label}: first event must be attempt-start`);
|
|
569
|
+
const terminal = attempt.result !== "running";
|
|
570
|
+
if (terminal && events.at(-1)?.event !== "attempt-end") errors.push(`${label}: terminal attempt must end with attempt-end`);
|
|
571
|
+
if (!terminal && events.some((event) => event.event === "attempt-end")) errors.push(`${label}: running attempt cannot contain attempt-end`);
|
|
572
|
+
if (events[0]?.at !== attempt.started_at) errors.push(`${label}: attempt-start timestamp does not bind attempt.started_at`);
|
|
573
|
+
if (terminal && events.at(-1)?.at !== attempt.ended_at) errors.push(`${label}: attempt-end timestamp does not bind attempt.ended_at`);
|
|
574
|
+
for (let index = 1; index < events.length; index += 1) if (Date.parse(events[index].at) < Date.parse(events[index - 1].at)) errors.push(`${label}: event timestamps are not monotonic at sequence ${events[index].sequence}`);
|
|
575
|
+
|
|
576
|
+
const operationStates = new Map(); const gateResults = new Map(); const passedGates = new Set(); const passedOperations = new Set(); const completedBatches = new Set(); const terminatedBatches = new Set(); const startedBatches = new Set();
|
|
577
|
+
let stopped = false; let stoppedOperation = null; let firstFailedOperation = null; let failureGateRecorded = false;
|
|
578
|
+
function refreshBatch(batchId) {
|
|
579
|
+
const operationIds = planMeta?.batchMap.get(batchId)?.operation_ids ?? [];
|
|
580
|
+
if (operationIds.length && operationIds.every((id) => passedOperations.has(id))) completedBatches.add(batchId);
|
|
581
|
+
}
|
|
582
|
+
for (const event of events) {
|
|
583
|
+
if (["attempt-start", "attempt-end"].includes(event.event)) continue;
|
|
584
|
+
const referencedGate = event.event === "gate-result" ? planMeta?.gateMap.get(event.gate_id) : null;
|
|
585
|
+
const closesFailedBatch = event.event === "gate-result" && !failureGateRecorded && ["failed", "blocked"].includes(event.result) && (referencedGate?.after_batches ?? []).every((batchId) => startedBatches.has(batchId) && terminatedBatches.has(batchId));
|
|
586
|
+
const allowedAfterStop = (event.event === "gate-result" && (event.result === "skipped" || closesFailedBatch)) || (event.event === "postcondition-result" && event.operation_id === stoppedOperation);
|
|
587
|
+
if (stopped && !allowedAfterStop) errors.push(`${label}: sequence ${event.sequence} continues execution after failure`);
|
|
588
|
+
if (event.event === "gate-result") {
|
|
589
|
+
const gate = referencedGate;
|
|
590
|
+
if (!gate) errors.push(`${label}: sequence ${event.sequence} references an unknown gate ${event.gate_id}`);
|
|
591
|
+
if (event.batch_id !== null && !gate?.after_batches?.includes(event.batch_id)) errors.push(`${label}: gate ${event.gate_id} batch_id is not one of its after_batches`);
|
|
592
|
+
if (gateResults.has(event.gate_id)) errors.push(`${label}: gate ${event.gate_id} has more than one result`);
|
|
593
|
+
if (event.result !== "skipped") {
|
|
594
|
+
for (const dependency of gate?.depends_on ?? []) if (gateResults.get(dependency)?.result !== "passed") errors.push(`${label}: gate ${event.gate_id} ran before dependency ${dependency} passed`);
|
|
595
|
+
for (const batchId of gate?.after_batches ?? []) {
|
|
596
|
+
const batchReady = event.result === "passed" ? completedBatches.has(batchId) : completedBatches.has(batchId) || (startedBatches.has(batchId) && terminatedBatches.has(batchId));
|
|
597
|
+
if (!batchReady) errors.push(`${label}: gate ${event.gate_id} ran before batch ${batchId} reached a terminal postcondition`);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
gateResults.set(event.gate_id, event); if (event.result === "passed") passedGates.add(event.gate_id);
|
|
601
|
+
if (["failed", "blocked"].includes(event.result)) { stopped = true; failureGateRecorded = true; }
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
const operation = planMeta?.operationMap.get(event.operation_id);
|
|
605
|
+
if (!operation || operation.batch_id !== event.batch_id) errors.push(`${label}: sequence ${event.sequence} references an unknown or mismatched operation ${event.operation_id}`);
|
|
606
|
+
const state = operationStates.get(event.operation_id) ?? { intent: null, result: null, postcondition: null };
|
|
607
|
+
if (event.event === "operation-intent") {
|
|
608
|
+
if (state.intent) errors.push(`${label}: duplicate intent for ${event.operation_id}`);
|
|
609
|
+
for (const dependency of planMeta?.dependencies.get(event.batch_id) ?? []) if (!completedBatches.has(dependency)) errors.push(`${label}: batch ${event.batch_id} started before dependency ${dependency} completed`);
|
|
610
|
+
for (const gateId of planMeta?.batchMap.get(event.batch_id)?.gate_ids ?? []) if (!passedGates.has(gateId)) errors.push(`${label}: batch ${event.batch_id} started before gate ${gateId} passed`);
|
|
611
|
+
const ordered = planMeta?.batchMap.get(event.batch_id)?.operation_ids ?? [];
|
|
612
|
+
for (const prior of ordered.slice(0, ordered.indexOf(event.operation_id))) if (!passedOperations.has(prior)) errors.push(`${label}: operation ${event.operation_id} started before ${prior} passed its postcondition`);
|
|
613
|
+
state.intent = event; startedBatches.add(event.batch_id);
|
|
614
|
+
} else if (event.event === "operation-result") {
|
|
615
|
+
if (!state.intent || state.result) errors.push(`${label}: operation-result for ${event.operation_id} lacks one preceding intent`);
|
|
616
|
+
state.result = event;
|
|
617
|
+
if (["failed", "blocked"].includes(event.result)) { stopped = true; stoppedOperation = event.operation_id; firstFailedOperation ??= event.operation_id; }
|
|
618
|
+
} else {
|
|
619
|
+
if (!state.result || state.postcondition) errors.push(`${label}: postcondition-result for ${event.operation_id} lacks one preceding operation-result`);
|
|
620
|
+
state.postcondition = event;
|
|
621
|
+
terminatedBatches.add(event.batch_id);
|
|
622
|
+
if (event.result === "passed") { passedOperations.add(event.operation_id); refreshBatch(event.batch_id); }
|
|
623
|
+
else if (["failed", "blocked"].includes(event.result)) { stopped = true; stoppedOperation = event.operation_id; firstFailedOperation ??= event.operation_id; }
|
|
624
|
+
}
|
|
625
|
+
operationStates.set(event.operation_id, state);
|
|
626
|
+
}
|
|
627
|
+
if (terminal) for (const [operationId, state] of operationStates) if (!state.intent || !state.result || !state.postcondition) errors.push(`${label}: terminal operation ${operationId} lacks intent, result or postcondition`);
|
|
628
|
+
if (!sameMembers(new Set(attempt.executed_batches ?? []), startedBatches)) errors.push(`${label}: executed_batches does not match journal intents`);
|
|
629
|
+
if (attempt.failed_operation !== firstFailedOperation) errors.push(`${label}: failed_operation does not match the first failed operation`);
|
|
630
|
+
const endResult = events.at(-1)?.event === "attempt-end" ? events.at(-1).result : null;
|
|
631
|
+
const expectedEnd = attempt.result === "succeeded" || attempt.result === "rolled_back" ? "passed" : attempt.result === "failed" ? "failed" : attempt.result === "blocked" ? "blocked" : attempt.result === "abandoned" ? "skipped" : null;
|
|
632
|
+
if (terminal && endResult !== expectedEnd) errors.push(`${label}: attempt-end result disagrees with attempt result`);
|
|
633
|
+
if (["succeeded", "rolled_back"].includes(attempt.result) && stopped) errors.push(`${label}: successful attempt contains a failed or blocked execution event`);
|
|
634
|
+
return { events, operationStates, gateResults, completedBatches, startedBatches };
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function identityMatches(assertion, result) {
|
|
638
|
+
if (assertion.comparison !== result.comparison) return false;
|
|
639
|
+
if (assertion.comparison === "ordered-list") return sameOrderedValues(assertion.expected, result.actual);
|
|
640
|
+
if (assertion.comparison === "set") return Array.isArray(assertion.expected) && Array.isArray(result.actual) && sameMembers(new Set(assertion.expected), new Set(result.actual));
|
|
641
|
+
if (assertion.comparison === "digest") return IMMUTABLE_DIGEST.test(assertion.expected ?? "") && IMMUTABLE_DIGEST.test(result.actual ?? "") && normalizedDigest(assertion.expected) === normalizedDigest(result.actual);
|
|
642
|
+
return JSON.stringify(assertion.expected) === JSON.stringify(result.actual);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function validateRetainedArtifacts(items, label, errors) {
|
|
646
|
+
if (!Array.isArray(items)) { errors.push(`${label}: retained_artifacts must be an array`); return; }
|
|
647
|
+
const ids = [];
|
|
648
|
+
for (const item of items) {
|
|
649
|
+
if (!exactKeys(item, ["id", "kind", "locator", "reason"], `${label} ${item?.id ?? "unknown"}`, errors)) continue;
|
|
650
|
+
ids.push(item.id);
|
|
651
|
+
if (!nonEmptyString(item.id) || !nonEmptyString(item.kind) || !nonEmptyString(item.locator) || !nonEmptyString(item.reason)) errors.push(`${label} ${item.id}: invalid retained artifact`);
|
|
652
|
+
}
|
|
653
|
+
if (duplicates(ids).length) errors.push(`${label}: duplicate retained artifact ids`);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function validateVerificationState(path, attempt, entry, profileMeta, planMeta, journalMeta, requirePassed, errors) {
|
|
657
|
+
const label = `${attempt.attempt_id}/verification-state.json`;
|
|
658
|
+
const value = readJson(path, label, errors); if (!value) return null;
|
|
659
|
+
const keys = ["schema_version", "artifact", "scope", "project_id", "change", "attempt_id", "captured_at", "target_profile_path", "target_profile_digest", "identity_results", "gates", "artifacts", "services", "probes", "convergence", "data_protection", "recovery", "retained_artifacts", "risks", "verdict"];
|
|
660
|
+
if (!exactKeys(value, keys, label, errors)) return null;
|
|
661
|
+
if (value.schema_version !== 1 || value.artifact !== "ops-verification-state" || value.scope !== entry.scope || value.project_id !== entry.project_id || value.change !== entry.change || value.attempt_id !== attempt.attempt_id || !validDateTime(value.captured_at) || !["passed", "failed", "blocked"].includes(value.verdict)) errors.push(`${label}: invalid identity, timestamp or verdict`);
|
|
662
|
+
if ((value.target_profile_path === null) !== (value.target_profile_digest === null) || !(value.target_profile_path === null || TARGET_PROFILE_LOCATOR.test(value.target_profile_path)) || !(value.target_profile_digest === null || DIGEST.test(value.target_profile_digest))) errors.push(`${label}: invalid target profile binding`);
|
|
663
|
+
if (value.target_profile_path !== attempt.target_profile_path || value.target_profile_digest !== attempt.target_profile_digest) errors.push(`${label}: target profile binding disagrees with attempt`);
|
|
664
|
+
if (profileMeta && value.target_profile_path !== null) {
|
|
665
|
+
if (value.target_profile_path !== profileMeta.locator || value.target_profile_digest !== profileMeta.digest) errors.push(`${label}: target profile digest binding mismatch`);
|
|
666
|
+
} else if (!profileMeta && (value.target_profile_path !== null || value.target_profile_digest !== null)) errors.push(`${label}: target profile binding points to a missing profile`);
|
|
667
|
+
if (requirePassed && (!profileMeta || value.target_profile_path !== profileMeta.locator || value.target_profile_digest !== profileMeta.digest)) errors.push(`${label}: terminal completion requires the current target profile binding`);
|
|
668
|
+
|
|
669
|
+
const identityIds = [];
|
|
670
|
+
if (!Array.isArray(value.identity_results)) errors.push(`${label}: identity_results must be an array`);
|
|
671
|
+
for (const result of value.identity_results ?? []) {
|
|
672
|
+
const resultLabel = `${label} identity ${result?.assertion_id ?? "unknown"}`;
|
|
673
|
+
if (!exactKeys(result, ["assertion_id", "comparison", "actual", "matched", "evidence"], resultLabel, errors)) continue;
|
|
674
|
+
identityIds.push(result.assertion_id);
|
|
675
|
+
const assertion = profileMeta?.assertionMap.get(result.assertion_id);
|
|
676
|
+
const actualShape = ["exact", "digest"].includes(result.comparison) ? nonEmptyString(result.actual) : validStringList(result.actual, { minimum: 1 });
|
|
677
|
+
const digestShape = result.comparison !== "digest" || IMMUTABLE_DIGEST.test(result.actual ?? "");
|
|
678
|
+
if (!assertion || !["exact", "ordered-list", "set", "digest"].includes(result.comparison) || !actualShape || !digestShape || typeof result.matched !== "boolean" || !validStringList(result.evidence, { minimum: 1 })) errors.push(`${resultLabel}: invalid or unknown identity result`);
|
|
679
|
+
else if (result.matched !== identityMatches(assertion, result)) errors.push(`${resultLabel}: matched flag disagrees with expected/actual comparison`);
|
|
680
|
+
}
|
|
681
|
+
if (duplicates(identityIds).length) errors.push(`${label}: duplicate identity results`);
|
|
682
|
+
if (profileMeta && !sameMembers(new Set(identityIds), new Set(profileMeta.assertionMap.keys()))) errors.push(`${label}: identity results do not cover the target profile exactly`);
|
|
683
|
+
if (requirePassed && (value.identity_results ?? []).some((result) => result.matched !== true)) errors.push(`${label}: passed verdict requires all identity assertions to match`);
|
|
684
|
+
|
|
685
|
+
const gateMap = new Map(); let gateStopped = false;
|
|
686
|
+
if (!Array.isArray(value.gates)) errors.push(`${label}: gates must be an array`);
|
|
687
|
+
for (const [index, gateResult] of (value.gates ?? []).entries()) {
|
|
688
|
+
const gateLabel = `${label} gate ${gateResult?.id ?? "unknown"}`;
|
|
689
|
+
if (!exactKeys(gateResult, ["id", "sequence", "status", "started_at", "ended_at", "evidence"], gateLabel, errors)) continue;
|
|
690
|
+
const gate = planMeta?.gateMap.get(gateResult.id);
|
|
691
|
+
if (!gate || gateMap.has(gateResult.id) || gateResult.sequence !== index + 1 || !["passed", "failed", "blocked", "skipped"].includes(gateResult.status) || !validDateTime(gateResult.started_at) || !validDateTime(gateResult.ended_at) || Date.parse(gateResult.ended_at) < Date.parse(gateResult.started_at) || !validStringList(gateResult.evidence)) errors.push(`${gateLabel}: invalid, duplicate or out-of-sequence gate result`);
|
|
692
|
+
if (gateStopped && gateResult.status !== "skipped") errors.push(`${gateLabel}: only skipped gates may follow a failed or blocked gate`);
|
|
693
|
+
if (gateResult.status !== "skipped") for (const dependency of gate?.depends_on ?? []) if (gateMap.get(dependency)?.status !== "passed") errors.push(`${gateLabel}: dependency ${dependency} did not pass first`);
|
|
694
|
+
if (["failed", "blocked"].includes(gateResult.status)) gateStopped = true;
|
|
695
|
+
gateMap.set(gateResult.id, gateResult);
|
|
696
|
+
const journalGate = journalMeta?.gateResults.get(gateResult.id);
|
|
697
|
+
if (!journalGate || journalGate.result !== gateResult.status) errors.push(`${gateLabel}: journal gate result is missing or disagrees`);
|
|
698
|
+
}
|
|
699
|
+
if (journalMeta && !sameMembers(new Set(gateMap.keys()), new Set(journalMeta.gateResults.keys()))) errors.push(`${label}: verification gates and journal gate results differ`);
|
|
700
|
+
if (requirePassed) for (const gate of planMeta?.gateMap.values() ?? []) if (gate.required && gateMap.get(gate.id)?.status !== "passed") errors.push(`${label}: required gate ${gate.id} did not pass`);
|
|
701
|
+
|
|
702
|
+
const artifactMap = new Map();
|
|
703
|
+
if (!Array.isArray(value.artifacts)) errors.push(`${label}: artifacts must be an array`);
|
|
704
|
+
for (const result of value.artifacts ?? []) {
|
|
705
|
+
const artifactLabel = `${label} artifact ${result?.requirement_id ?? "unknown"}`;
|
|
706
|
+
if (!exactKeys(result, ["requirement_id", "immutable_digest", "target_ref", "server_verified", "matched", "evidence"], artifactLabel, errors)) continue;
|
|
707
|
+
const requirement = planMeta?.artifactMap.get(result.requirement_id);
|
|
708
|
+
const computedMatch = Boolean(requirement) && normalizedDigest(requirement.immutable_digest) === normalizedDigest(result.immutable_digest);
|
|
709
|
+
if (!requirement || artifactMap.has(result.requirement_id) || !IMMUTABLE_DIGEST.test(result.immutable_digest ?? "") || !nonEmptyString(result.target_ref) || typeof result.server_verified !== "boolean" || typeof result.matched !== "boolean" || !validStringList(result.evidence) || result.matched !== computedMatch) errors.push(`${artifactLabel}: invalid artifact evidence or digest comparison`);
|
|
710
|
+
artifactMap.set(result.requirement_id, result);
|
|
711
|
+
}
|
|
712
|
+
if (requirePassed) for (const requirement of planMeta?.artifactMap.values() ?? []) if (requirement.required && (artifactMap.get(requirement.id)?.matched !== true || artifactMap.get(requirement.id)?.server_verified !== true)) errors.push(`${label}: required artifact ${requirement.id} lacks target-side verification`);
|
|
713
|
+
|
|
714
|
+
const serviceMap = new Map();
|
|
715
|
+
if (!Array.isArray(value.services)) errors.push(`${label}: services must be an array`);
|
|
716
|
+
for (const service of value.services ?? []) {
|
|
717
|
+
const serviceLabel = `${label} service ${service?.id ?? "unknown"}`;
|
|
718
|
+
if (!exactKeys(service, ["id", "status", "immutable_ref", "restart_count", "runtime_digest", "evidence"], serviceLabel, errors)) continue;
|
|
719
|
+
if (!nonEmptyString(service.id) || serviceMap.has(service.id) || !["healthy", "running", "degraded", "failed", "absent"].includes(service.status) || !(service.immutable_ref === null || nonEmptyString(service.immutable_ref)) || !Number.isInteger(service.restart_count) || service.restart_count < 0 || !(service.runtime_digest === null || nonEmptyString(service.runtime_digest)) || !validStringList(service.evidence)) errors.push(`${serviceLabel}: invalid or duplicate service result`);
|
|
720
|
+
serviceMap.set(service.id, service);
|
|
721
|
+
}
|
|
722
|
+
if (requirePassed && [...serviceMap.values()].some((service) => ["degraded", "failed"].includes(service.status))) errors.push(`${label}: passed verdict cannot contain degraded or failed services`);
|
|
723
|
+
|
|
724
|
+
const probeMap = new Map();
|
|
725
|
+
if (!Array.isArray(value.probes)) errors.push(`${label}: probes must be an array`);
|
|
726
|
+
for (const probe of value.probes ?? []) {
|
|
727
|
+
const probeLabel = `${label} probe ${probe?.verification_id ?? "unknown"}`;
|
|
728
|
+
if (!exactKeys(probe, ["verification_id", "ok", "http_status", "business_code", "authenticated", "consecutive_successes", "elapsed_seconds", "transient_failures", "evidence"], probeLabel, errors)) continue;
|
|
729
|
+
const requirement = planMeta?.verificationMap.get(probe.verification_id);
|
|
730
|
+
if (!requirement || probeMap.has(probe.verification_id) || typeof probe.ok !== "boolean" || !(probe.http_status === null || Number.isInteger(probe.http_status)) || !(probe.business_code === null || typeof probe.business_code === "string" || Number.isInteger(probe.business_code)) || !(probe.authenticated === null || typeof probe.authenticated === "boolean") || !Number.isInteger(probe.consecutive_successes) || probe.consecutive_successes < 0 || !(probe.elapsed_seconds >= 0) || !Array.isArray(probe.transient_failures) || !validStringList(probe.evidence)) errors.push(`${probeLabel}: invalid or duplicate probe result`);
|
|
731
|
+
if (requirement) {
|
|
732
|
+
const expected = requirement.expected;
|
|
733
|
+
const httpMatches = !expected.http_statuses.length || expected.http_statuses.includes(probe.http_status);
|
|
734
|
+
const businessMatches = !expected.business_codes.length || expected.business_codes.includes(probe.business_code);
|
|
735
|
+
const authenticationMatches = expected.authenticated === null || expected.authenticated === probe.authenticated;
|
|
736
|
+
const stabilityMatches = requirement.stability === null || (probe.consecutive_successes >= requirement.stability.required_consecutive_successes && probe.elapsed_seconds <= requirement.stability.timeout_seconds);
|
|
737
|
+
const typedMatch = httpMatches && businessMatches && authenticationMatches && stabilityMatches;
|
|
738
|
+
const hasMachineExpectation = requirement.kind === "http" || expected.http_statuses.length || expected.business_codes.length || expected.authenticated !== null || requirement.stability !== null;
|
|
739
|
+
if (hasMachineExpectation && probe.ok !== typedMatch) errors.push(`${probeLabel}: ok flag disagrees with typed expectation or stability window`);
|
|
740
|
+
}
|
|
741
|
+
probeMap.set(probe.verification_id, probe);
|
|
742
|
+
}
|
|
743
|
+
if (requirePassed) for (const requirement of planMeta?.verificationMap.values() ?? []) if (requirement.required && probeMap.get(requirement.id)?.ok !== true) errors.push(`${label}: required verification ${requirement.id} did not pass`);
|
|
744
|
+
for (const [gateId, gateResult] of gateMap) if (gateResult.status === "passed") for (const verificationId of planMeta?.gateMap.get(gateId)?.verification_ids ?? []) if (probeMap.get(verificationId)?.ok !== true) errors.push(`${label}: passed gate ${gateId} has failing or missing verification ${verificationId}`);
|
|
745
|
+
|
|
746
|
+
const convergenceGroups = new Map();
|
|
747
|
+
if (!Array.isArray(value.convergence)) errors.push(`${label}: convergence must be an array`);
|
|
748
|
+
for (const convergence of value.convergence ?? []) {
|
|
749
|
+
const convergenceLabel = `${label} convergence ${convergence?.group ?? "unknown"}`;
|
|
750
|
+
if (!exactKeys(convergence, ["group", "member_ids", "digests", "matched", "evidence"], convergenceLabel, errors)) continue;
|
|
751
|
+
const computedMatch = Array.isArray(convergence.digests) && convergence.digests.length >= 2 && new Set(convergence.digests).size === 1;
|
|
752
|
+
if (!nonEmptyString(convergence.group) || convergenceGroups.has(convergence.group) || !validStringList(convergence.member_ids, { minimum: 2 }) || !Array.isArray(convergence.digests) || convergence.digests.length !== convergence.member_ids.length || convergence.digests.some((digest) => !nonEmptyString(digest)) || typeof convergence.matched !== "boolean" || convergence.matched !== computedMatch || !validStringList(convergence.evidence) || convergence.member_ids.some((id) => !serviceMap.has(id))) errors.push(`${convergenceLabel}: invalid convergence result`);
|
|
753
|
+
convergenceGroups.set(convergence.group, convergence);
|
|
754
|
+
}
|
|
755
|
+
if (requirePassed) for (const requirement of planMeta?.verificationMap.values() ?? []) if (requirement.required && requirement.convergence_group && convergenceGroups.get(requirement.convergence_group)?.matched !== true) errors.push(`${label}: convergence group ${requirement.convergence_group} did not match`);
|
|
756
|
+
|
|
757
|
+
const protectionMap = new Map();
|
|
758
|
+
if (!Array.isArray(value.data_protection)) errors.push(`${label}: data_protection must be an array`);
|
|
759
|
+
for (const result of value.data_protection ?? []) {
|
|
760
|
+
const protectionLabel = `${label} data protection ${result?.protection_id ?? "unknown"}`;
|
|
761
|
+
if (!exactKeys(result, ["protection_id", "status", "evidence_path", "evidence_digest", "readability_verified", "restore_verified"], protectionLabel, errors)) continue;
|
|
762
|
+
const requirement = planMeta?.protectionMap.get(result.protection_id);
|
|
763
|
+
if (!requirement || protectionMap.has(result.protection_id) || !["verified", "waived", "not-required", "failed"].includes(result.status) || !(result.evidence_path === null || nonEmptyString(result.evidence_path)) || !(result.evidence_digest === null || DIGEST.test(result.evidence_digest)) || typeof result.readability_verified !== "boolean" || typeof result.restore_verified !== "boolean") errors.push(`${protectionLabel}: invalid or duplicate protection result`);
|
|
764
|
+
if (result.status !== "failed" || requirePassed) {
|
|
765
|
+
if (requirement?.strategy === "verified-backup" && (result.status !== "verified" || !result.evidence_path || !result.evidence_digest || !result.readability_verified || !result.restore_verified)) errors.push(`${protectionLabel}: verified backup must be readable and restore-verified`);
|
|
766
|
+
if (requirement?.strategy === "waiver" && result.status !== "waived") errors.push(`${protectionLabel}: waiver result does not match plan`);
|
|
767
|
+
if (requirement?.strategy === "not-required" && result.status !== "not-required") errors.push(`${protectionLabel}: not-required result does not match plan`);
|
|
768
|
+
}
|
|
769
|
+
protectionMap.set(result.protection_id, result);
|
|
770
|
+
}
|
|
771
|
+
if (planMeta && !sameMembers(new Set(protectionMap.keys()), new Set(planMeta.protectionMap.keys()))) errors.push(`${label}: data protection results do not cover the plan exactly`);
|
|
772
|
+
|
|
773
|
+
const recovery = value.recovery;
|
|
774
|
+
if (!exactKeys(recovery, ["strategy", "previous_release_ref", "rollback_operation_refs", "material_refs", "compatibility_verified", "data_restore_authorized", "evidence"], `${label} recovery`, errors) || !["restore-previous", "reverse-change", "forward-fix", "not-applicable"].includes(recovery?.strategy) || !(recovery?.previous_release_ref === null || nonEmptyString(recovery.previous_release_ref)) || !validStringList(recovery?.rollback_operation_refs ?? []) || (recovery?.rollback_operation_refs ?? []).some((id) => !OPERATION_ID.test(id) || (planMeta && !planMeta.operationMap.has(id))) || !validStringList(recovery?.material_refs ?? []) || typeof recovery?.compatibility_verified !== "boolean" || !(recovery?.data_restore_authorized === null || typeof recovery.data_restore_authorized === "boolean") || !validStringList(recovery?.evidence ?? [])) errors.push(`${label}: invalid recovery evidence`);
|
|
775
|
+
if (requirePassed && planMeta && (recovery?.strategy === "not-applicable" || recovery?.compatibility_verified !== true || !(recovery?.rollback_operation_refs?.length || recovery?.material_refs?.length))) errors.push(`${label}: completed mutation requires verified recovery strategy and rollback material`);
|
|
776
|
+
if (requirePassed && !planMeta && recovery?.strategy !== "not-applicable" && recovery?.compatibility_verified !== true) errors.push(`${label}: recovery compatibility was not verified`);
|
|
777
|
+
validateRetainedArtifacts(value.retained_artifacts, `${label} retained artifacts`, errors);
|
|
778
|
+
if (!validStringList(value.risks)) errors.push(`${label}: risks must contain unique non-empty strings`);
|
|
779
|
+
if (requirePassed && value.verdict !== "passed") errors.push(`${label}: terminal completion requires a passed verdict`);
|
|
780
|
+
return { value, digest: canonicalDigest(value), gateMap, artifactMap, probeMap, protectionMap };
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
function validateHandoff(path, attempt, errors) {
|
|
784
|
+
const label = `${attempt.attempt_id}/HANDOFF.md`;
|
|
785
|
+
if (!isFile(path)) { errors.push(`${label}: missing`); return; }
|
|
786
|
+
const text = readFileSync(path, "utf8");
|
|
787
|
+
const headings = ["## Target and Control-plane Identity", "## Source and Immutable Artifacts", "## Gate Results", "## Semantic Probes and Stability Windows", "## Data Protection", "## Recovery and Previous Release", "## Remaining Risks and Operator Follow-up"];
|
|
788
|
+
for (const heading of headings) if (!text.includes(heading)) errors.push(`${label}: missing ${heading}`);
|
|
789
|
+
if (containsSecretMaterial(text)) errors.push(`${label}: contains secret material`);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
function validateAttempts(changeRoot, entry, status, profileMeta, strictLatest, errors) {
|
|
793
|
+
const attemptsRoot = join(changeRoot, "execution", "attempts");
|
|
794
|
+
const names = isDirectory(attemptsRoot) ? readdirSync(attemptsRoot, { withFileTypes: true }).filter((item) => item.isDirectory()).map((item) => item.name).sort() : [];
|
|
795
|
+
for (const [index, name] of names.entries()) if (name !== `ATTEMPT-${String(index + 1).padStart(3, "0")}`) errors.push(`${entryKey(entry)}: attempts must be contiguous from ATTEMPT-001; found ${name}`);
|
|
796
|
+
let latest = null; let latestAttempt = null; let latestVersion = null; let latestVerification = null;
|
|
797
|
+
for (const [attemptIndex, name] of names.entries()) {
|
|
798
|
+
if (!ATTEMPT_ID.test(name)) { errors.push(`${entryKey(entry)}: invalid attempt directory ${name}`); continue; }
|
|
799
|
+
latest = name;
|
|
800
|
+
const root = join(attemptsRoot, name); const path = join(root, "attempt.json");
|
|
801
|
+
if (!isFile(path)) { errors.push(`${name}: attempt.json missing`); continue; }
|
|
802
|
+
const value = readJson(path, `${name}/attempt.json`, errors); if (!value) continue;
|
|
803
|
+
latestAttempt = value; latestVersion = value.schema_version;
|
|
804
|
+
const v1Keys = ["schema_version", "artifact", "scope", "project_id", "change", "attempt_id", "kind", "triggered_by_attempt", "plan_path", "plan_digest", "approval_path", "started_at", "ended_at", "result", "executed_batches", "failed_operation", "mutation_performed", "journal_path", "diagnosis_path", "verification_path", "blockers"];
|
|
805
|
+
const v2Keys = ["schema_version", "artifact", "scope", "project_id", "change", "attempt_id", "kind", "triggered_by_attempt", "plan_path", "plan_digest", "approval_path", "target_profile_path", "target_profile_digest", "started_at", "ended_at", "result", "executed_batches", "failed_operation", "mutation_performed", "journal_path", "diagnosis_path", "verification_path", "verification_state_path", "verification_state_digest", "handoff_path", "retained_artifacts", "blockers"];
|
|
806
|
+
const keys = value.schema_version === 1 ? v1Keys : value.schema_version === 2 ? v2Keys : null;
|
|
807
|
+
if (!keys || !exactKeys(value, keys, `${name}/attempt.json`, errors)) { if (!keys) errors.push(`${name}: unsupported attempt schema version`); continue; }
|
|
808
|
+
if (value.artifact !== "ops-execution-attempt" || value.scope !== entry.scope || value.project_id !== entry.project_id || value.change !== entry.change || value.attempt_id !== name || !["deploy", "remediation", "rollback", "verification-only"].includes(value.kind)) errors.push(`${name}: invalid identity/kind`);
|
|
809
|
+
if (!isFile(join(root, "journal.jsonl")) || !isFile(join(root, "summary.md")) || value.journal_path !== `execution/attempts/${name}/journal.jsonl`) errors.push(`${name}: journal/summary contract is incomplete`);
|
|
810
|
+
if (value.kind === "verification-only") { if (value.plan_path !== null || value.plan_digest !== null || value.approval_path !== null || value.mutation_performed !== false) errors.push(`${name}: verification-only cannot bind plan or mutation`); }
|
|
811
|
+
else if (!PLAN_LOCATOR.test(value.plan_path ?? "") || !DIGEST.test(value.plan_digest ?? "") || !APPROVAL_LOCATOR.test(value.approval_path ?? "")) errors.push(`${name}: mutation attempt requires plan/approval binding`);
|
|
812
|
+
if (!(value.triggered_by_attempt === null || ATTEMPT_ID.test(value.triggered_by_attempt)) || (value.triggered_by_attempt !== null && (!names.slice(0, attemptIndex).includes(value.triggered_by_attempt))) || !validDateTime(value.started_at) || !(value.ended_at === null || validDateTime(value.ended_at)) || !["running", "succeeded", "failed", "blocked", "rolled_back", "abandoned"].includes(value.result)) errors.push(`${name}: invalid lineage/time/result`);
|
|
813
|
+
if (value.result === "running" ? value.ended_at !== null : !validDateTime(value.ended_at)) errors.push(`${name}: terminal/time mismatch`);
|
|
814
|
+
if (validDateTime(value.started_at) && validDateTime(value.ended_at) && Date.parse(value.ended_at) < Date.parse(value.started_at)) errors.push(`${name}: ended_at precedes started_at`);
|
|
815
|
+
if (!validStringList(value.executed_batches ?? []) || (value.executed_batches ?? []).some((id) => !BATCH_ID.test(id)) || !(value.failed_operation === null || OPERATION_ID.test(value.failed_operation)) || typeof value.mutation_performed !== "boolean" || !validStringList(value.blockers ?? [])) errors.push(`${name}: invalid batches, operation, mutation or blockers`);
|
|
816
|
+
if (value.kind === "verification-only" && value.executed_batches.length) errors.push(`${name}: verification-only cannot execute mutation batches`);
|
|
817
|
+
const expectedDiagnosis = `execution/attempts/${name}/diagnosis.md`; const expectedVerification = `execution/attempts/${name}/verification.md`;
|
|
818
|
+
if (value.diagnosis_path !== null && (value.diagnosis_path !== expectedDiagnosis || !isFile(join(root, "diagnosis.md")))) errors.push(`${name}: diagnosis locator mismatch`);
|
|
819
|
+
if (["failed", "blocked"].includes(value.result) && value.diagnosis_path !== expectedDiagnosis) errors.push(`${name}: failed/blocked attempt requires diagnosis`);
|
|
820
|
+
if (value.verification_path !== null && (value.verification_path !== expectedVerification || !isFile(join(root, "verification.md")))) errors.push(`${name}: verification locator mismatch`);
|
|
821
|
+
if (value.schema_version === 1) {
|
|
822
|
+
if (["succeeded", "rolled_back"].includes(value.result) && value.verification_path !== expectedVerification) errors.push(`${name}: successful terminal attempt requires verification`);
|
|
823
|
+
continue;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
validateRetainedArtifacts(value.retained_artifacts, `${name}/attempt.json retained artifacts`, errors);
|
|
827
|
+
const hasProfilePath = value.target_profile_path !== null; const hasProfileDigest = value.target_profile_digest !== null;
|
|
828
|
+
if (hasProfilePath !== hasProfileDigest || !(value.target_profile_path === null || TARGET_PROFILE_LOCATOR.test(value.target_profile_path)) || !(value.target_profile_digest === null || DIGEST.test(value.target_profile_digest))) errors.push(`${name}: invalid target profile binding`);
|
|
829
|
+
if (hasProfilePath && (!profileMeta || value.target_profile_path !== profileMeta.locator || value.target_profile_digest !== profileMeta.digest)) errors.push(`${name}: target profile digest binding mismatch`);
|
|
830
|
+
if (value.kind !== "verification-only" && (!profileMeta || value.target_profile_path !== profileMeta.locator || value.target_profile_digest !== profileMeta.digest)) errors.push(`${name}: mutation attempt requires the current target profile binding`);
|
|
831
|
+
|
|
832
|
+
const planBinding = value.kind === "verification-only" ? null : validateAttemptPlanBinding(changeRoot, entry, value, profileMeta, errors);
|
|
833
|
+
if (value.kind !== "verification-only" && planBinding?.meta.version !== 3) errors.push(`${name}: a v2 mutation attempt must bind a plan v3`);
|
|
834
|
+
if (planBinding && (planBinding.meta.bindings.target_profile_path !== value.target_profile_path || planBinding.meta.bindings.target_profile_digest !== value.target_profile_digest)) errors.push(`${name}: attempt and plan target profile bindings disagree`);
|
|
835
|
+
const journalMeta = isFile(join(root, "journal.jsonl")) ? readJournal(join(root, "journal.jsonl"), value, planBinding?.meta ?? null, errors) : null;
|
|
836
|
+
|
|
837
|
+
const terminal = value.result !== "running";
|
|
838
|
+
const isLatest = attemptIndex === names.length - 1;
|
|
839
|
+
const completionAbandoned = value.result === "abandoned" && isLatest && ["completed", "archived"].includes(status.change_status);
|
|
840
|
+
if (terminal && value.verification_path !== expectedVerification) errors.push(`${name}: terminal v2 attempt requires verification Markdown`);
|
|
841
|
+
const expectedState = `execution/attempts/${name}/verification-state.json`;
|
|
842
|
+
const statePair = value.verification_state_path !== null || value.verification_state_digest !== null;
|
|
843
|
+
if ((value.verification_state_path === null) !== (value.verification_state_digest === null) || !(value.verification_state_path === null || VERIFICATION_STATE_LOCATOR.test(value.verification_state_path)) || !(value.verification_state_digest === null || DIGEST.test(value.verification_state_digest))) errors.push(`${name}: invalid verification state binding`);
|
|
844
|
+
if (terminal && (value.verification_state_path !== expectedState || !DIGEST.test(value.verification_state_digest ?? ""))) errors.push(`${name}: terminal v2 attempt requires verification-state binding`);
|
|
845
|
+
let verificationMeta = null;
|
|
846
|
+
if (statePair) {
|
|
847
|
+
const statePath = resolveChangeRelative(changeRoot, value.verification_state_path, `${name} verification_state_path`, errors);
|
|
848
|
+
if (!statePath || !isFile(statePath)) errors.push(`${name}: verification-state file is missing`);
|
|
849
|
+
else {
|
|
850
|
+
verificationMeta = validateVerificationState(statePath, value, entry, profileMeta, planBinding?.meta ?? null, journalMeta, ["succeeded", "rolled_back"].includes(value.result) || completionAbandoned, errors);
|
|
851
|
+
if (verificationMeta && verificationMeta.digest !== value.verification_state_digest) errors.push(`${name}: verification-state digest binding mismatch`);
|
|
852
|
+
if (verificationMeta && canonicalDigest(verificationMeta.value.retained_artifacts) !== canonicalDigest(value.retained_artifacts)) errors.push(`${name}: retained artifacts disagree with verification state`);
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
const handoffRequired = ["succeeded", "rolled_back"].includes(value.result) || completionAbandoned;
|
|
857
|
+
const expectedHandoff = `execution/attempts/${name}/HANDOFF.md`;
|
|
858
|
+
if (!(value.handoff_path === null || HANDOFF_LOCATOR.test(value.handoff_path))) errors.push(`${name}: invalid HANDOFF binding`);
|
|
859
|
+
if (handoffRequired && value.handoff_path !== expectedHandoff) errors.push(`${name}: terminal completion attempt requires HANDOFF`);
|
|
860
|
+
if (value.handoff_path !== null) {
|
|
861
|
+
const handoffPath = resolveChangeRelative(changeRoot, value.handoff_path, `${name} handoff_path`, errors);
|
|
862
|
+
if (handoffPath) validateHandoff(handoffPath, value, errors);
|
|
863
|
+
}
|
|
864
|
+
if (isLatest) latestVerification = verificationMeta;
|
|
865
|
+
}
|
|
866
|
+
if (status.latest_attempt_id !== latest) errors.push(`${entryKey(entry)}: latest_attempt_id does not match latest attempt`);
|
|
867
|
+
if (strictLatest && latest !== null && latestVersion !== 2) errors.push(`${entryKey(entry)}: pre-close/pre-archive requires latest attempt schema v2`);
|
|
868
|
+
return { latest, latestAttempt, latestVersion, latestVerification };
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
function validateRetrospective(changeRoot, errors) {
|
|
872
|
+
const path = join(changeRoot, "RETROSPECTIVE.md");
|
|
873
|
+
if (!isFile(path)) { errors.push("RETROSPECTIVE.md: missing"); return; }
|
|
874
|
+
const text = readFileSync(path, "utf8");
|
|
875
|
+
for (const heading of RETROSPECTIVE_HEADINGS) if (!text.includes(heading)) errors.push(`RETROSPECTIVE.md: missing ${heading}`);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
function allowedPromotionTarget(entry, targetPath) {
|
|
879
|
+
const roots = entry.scope === "project"
|
|
880
|
+
? [`projects/${entry.project_id}/context/`, `projects/${entry.project_id}/adr/`, `projects/${entry.project_id}/runbooks/`, "context/", "adr/", "runbooks/"]
|
|
881
|
+
: ["context/", "adr/", "runbooks/"];
|
|
882
|
+
return roots.some((root) => targetPath.startsWith(root) && !targetPath.slice(root.length).split("/").includes(".."));
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
function validatePromotion(stateRoot, changeRoot, entry, complete, errors) {
|
|
886
|
+
validateRetrospective(changeRoot, errors);
|
|
887
|
+
if (!isFile(join(changeRoot, "promotion", "plan.md"))) errors.push("promotion/plan.md: missing");
|
|
888
|
+
const manifestPath = join(changeRoot, "promotion", "manifest.json");
|
|
889
|
+
if (!isFile(manifestPath)) { errors.push("promotion/manifest.json: missing"); return; }
|
|
890
|
+
const manifest = readJson(manifestPath, "promotion/manifest.json", errors); if (!manifest) return;
|
|
891
|
+
const keys = ["schema_version", "artifact", "scope", "project_id", "change", "created_at", "source_path", "archive_path", "retrospective_path", "writes", "archive_only", "summary"];
|
|
892
|
+
if (!exactKeys(manifest, keys, "promotion/manifest.json", errors)) return;
|
|
893
|
+
if (manifest.schema_version !== 1 || manifest.artifact !== "ops-promotion-manifest" || manifest.scope !== entry.scope || manifest.project_id !== entry.project_id || manifest.change !== entry.change || manifest.source_path !== stateRelativeActive(entry) || manifest.archive_path !== stateRelativeArchive(entry) || manifest.retrospective_path !== "RETROSPECTIVE.md" || !validDateTime(manifest.created_at) || !Array.isArray(manifest.writes) || !Array.isArray(manifest.archive_only) || !manifest.summary) errors.push("promotion/manifest.json: invalid identity or paths");
|
|
894
|
+
for (const item of manifest.writes ?? []) {
|
|
895
|
+
if (!exactKeys(item, ["stable_key", "knowledge_scope", "action", "staging_path", "target_path", "content_digest", "expected_target_digest", "evidence"], `promotion write ${item?.stable_key}`, errors)) continue;
|
|
896
|
+
const staging = resolveChangeRelative(changeRoot, item.staging_path, `promotion staging ${item.stable_key}`, errors);
|
|
897
|
+
if (!item.staging_path.startsWith("promotion/staging/") || !staging || !isFile(staging) || fileDigest(staging) !== item.content_digest || !DIGEST.test(item.content_digest ?? "") || !(item.expected_target_digest === null || DIGEST.test(item.expected_target_digest)) || !allowedPromotionTarget(entry, item.target_path)) errors.push(`promotion write ${item.stable_key}: invalid staging, digest or target`);
|
|
898
|
+
const target = resolveStateRelative(stateRoot, item.target_path, `promotion target ${item.stable_key}`, errors);
|
|
899
|
+
if (!target) continue;
|
|
900
|
+
if (!complete) {
|
|
901
|
+
if (item.expected_target_digest === null ? existsSync(target) : !isFile(target) || fileDigest(target) !== item.expected_target_digest) errors.push(`promotion write ${item.stable_key}: target drift before archive`);
|
|
902
|
+
} else if (!isFile(target) || fileDigest(target) !== item.content_digest) errors.push(`promotion write ${item.stable_key}: promoted target content mismatch`);
|
|
903
|
+
}
|
|
904
|
+
const approvalPath = join(changeRoot, "promotion", "approval.json");
|
|
905
|
+
if (complete || isFile(approvalPath)) {
|
|
906
|
+
const approval = isFile(approvalPath) ? readJson(approvalPath, "promotion/approval.json", errors) : null;
|
|
907
|
+
if (!approval) { errors.push("promotion/approval.json: missing"); return; }
|
|
908
|
+
const approvalKeys = ["schema_version", "artifact", "scope", "project_id", "change", "manifest_path", "manifest_digest", "decision", "decision_summary", "decided_at"];
|
|
909
|
+
if (!exactKeys(approval, approvalKeys, "promotion/approval.json", errors) || approval.schema_version !== 1 || approval.artifact !== "ops-promotion-approval" || approval.scope !== entry.scope || approval.project_id !== entry.project_id || approval.change !== entry.change || approval.manifest_path !== "promotion/manifest.json" || approval.manifest_digest !== canonicalDigest(manifest) || approval.decision !== "approved" || !approval.decision_summary || !validDateTime(approval.decided_at)) errors.push("promotion/approval.json: invalid manifest binding");
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
function validateCompletion(entry, status, attemptsMeta, errors) {
|
|
914
|
+
if (!["completed", "archived"].includes(status.change_status)) return;
|
|
915
|
+
if (!status.latest_attempt_id) { errors.push(`${entryKey(entry)}: terminal change requires an attempt`); return; }
|
|
916
|
+
const attempt = attemptsMeta?.latestAttempt;
|
|
917
|
+
if (!attempt || !["succeeded", "rolled_back", "abandoned"].includes(attempt.result) || (status.outcome === "succeeded" && attempt.result !== "succeeded") || (status.outcome === "rolled_back" && attempt.result !== "rolled_back") || (status.outcome === "abandoned" && attempt.result !== "abandoned")) errors.push(`${entryKey(entry)}: outcome disagrees with latest terminal attempt`);
|
|
918
|
+
if (attemptsMeta?.latestVersion === 2 && !attemptsMeta.latestVerification) errors.push(`${entryKey(entry)}: terminal change lacks latest v2 verification state`);
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
function collectFoundEntries(stateRoot) {
|
|
922
|
+
const active = []; const archived = [];
|
|
923
|
+
for (const item of isDirectory(join(stateRoot, "changes")) ? readdirSync(join(stateRoot, "changes"), { withFileTypes: true }) : []) if (item.isDirectory()) active.push({ scope: "global", project_id: null, change: item.name });
|
|
924
|
+
for (const month of isDirectory(join(stateRoot, "archive")) ? readdirSync(join(stateRoot, "archive"), { withFileTypes: true }) : []) if (month.isDirectory()) for (const item of readdirSync(join(stateRoot, "archive", month.name), { withFileTypes: true })) if (item.isDirectory()) archived.push({ scope: "global", project_id: null, change: item.name });
|
|
925
|
+
const projects = isDirectory(join(stateRoot, "projects")) ? readdirSync(join(stateRoot, "projects"), { withFileTypes: true }).filter((item) => item.isDirectory()).map((item) => item.name) : [];
|
|
926
|
+
for (const project of projects) {
|
|
927
|
+
for (const item of isDirectory(join(projectRoot(stateRoot, project), "changes")) ? readdirSync(join(projectRoot(stateRoot, project), "changes"), { withFileTypes: true }) : []) if (item.isDirectory()) active.push({ scope: "project", project_id: project, change: item.name });
|
|
928
|
+
const archive = join(projectRoot(stateRoot, project), "archive");
|
|
929
|
+
for (const month of isDirectory(archive) ? readdirSync(archive, { withFileTypes: true }) : []) if (month.isDirectory()) for (const item of readdirSync(join(archive, month.name), { withFileTypes: true })) if (item.isDirectory()) archived.push({ scope: "project", project_id: project, change: item.name });
|
|
930
|
+
}
|
|
931
|
+
return { active, archived, projects };
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
function validateStateRoot(stateRoot, options, errors) {
|
|
935
|
+
validateNoSymlinks(stateRoot, errors);
|
|
936
|
+
const statusPath = join(stateRoot, "status.json");
|
|
937
|
+
if (!isFile(statusPath)) { errors.push("status.json: missing"); return; }
|
|
938
|
+
const status = readJson(statusPath, "status.json", errors); const index = status && validateGlobalStatus(status, errors); if (!index) return;
|
|
939
|
+
const found = collectFoundEntries(stateRoot);
|
|
940
|
+
for (const project of found.projects) {
|
|
941
|
+
if (!PROJECT_ID.test(project)) errors.push(`projects/${project}: invalid project id`);
|
|
942
|
+
const path = join(projectRoot(stateRoot, project), "project.json");
|
|
943
|
+
if (!isFile(path)) errors.push(`projects/${project}/project.json: missing`); else validateProject(readJson(path, `projects/${project}/project.json`, errors), project, errors);
|
|
944
|
+
}
|
|
945
|
+
const activeKeys = new Set(index.active.map(entryKey)); const archivedKeys = new Set(index.archived.map(entryKey));
|
|
946
|
+
for (const entry of found.active) if (!activeKeys.has(entryKey(entry))) errors.push(`${stateRelativeActive(entry)}: directory is not indexed active`);
|
|
947
|
+
for (const entry of found.archived) if (!archivedKeys.has(entryKey(entry))) errors.push(`${stateRelativeArchive(entry)}: directory is not indexed archived`);
|
|
948
|
+
for (const entry of index.active) {
|
|
949
|
+
const root = activeRoot(stateRoot, entry); const path = join(root, ".status.json");
|
|
950
|
+
if (!isFile(path)) { errors.push(`${entryKey(entry)}: active .status.json missing`); continue; }
|
|
951
|
+
const value = readJson(path, `${entryKey(entry)}/.status.json`, errors); if (!value) continue;
|
|
952
|
+
validateChangeStatus(value, entry, false, errors);
|
|
953
|
+
const domain = validateDomainArtifacts(root, entry, value, errors);
|
|
954
|
+
validatePlanAndApproval(root, entry, value, domain.profileMeta, options.stage === "pre-execute" && selected(options, entry), errors);
|
|
955
|
+
const strictLatest = selected(options, entry) && ["pre-close", "pre-archive"].includes(options.stage);
|
|
956
|
+
const attempts = validateAttempts(root, entry, value, domain.profileMeta, strictLatest, errors);
|
|
957
|
+
validateCompletion(entry, value, attempts, errors);
|
|
958
|
+
if (options.stage === "pre-execute" && selected(options, entry) && (value.phase !== "approved" || value.approval_status !== "approved")) errors.push(`${entryKey(entry)}: pre-execute gate failed`);
|
|
959
|
+
if (options.stage === "pre-close" && selected(options, entry) && (value.change_status !== "completed" || value.phase !== "ready_to_archive")) errors.push(`${entryKey(entry)}: pre-close gate failed`);
|
|
960
|
+
if (options.stage === "pre-archive" && selected(options, entry)) { if (value.change_status !== "completed" || value.phase !== "ready_to_archive") errors.push(`${entryKey(entry)}: pre-archive completion gate failed`); validatePromotion(stateRoot, root, entry, false, errors); }
|
|
961
|
+
if (options.stage === "complete" && selected(options, entry)) errors.push(`${entryKey(entry)}: complete stage cannot leave change active`);
|
|
962
|
+
}
|
|
963
|
+
for (const entry of index.archived) {
|
|
964
|
+
const root = archivedRoot(stateRoot, entry); const path = join(root, ".status.json");
|
|
965
|
+
if (!isFile(path)) { errors.push(`${entryKey(entry)}: archived .status.json missing`); continue; }
|
|
966
|
+
const value = readJson(path, `${entryKey(entry)}/.status.json`, errors); if (!value) continue;
|
|
967
|
+
validateChangeStatus(value, entry, true, errors);
|
|
968
|
+
const domain = validateDomainArtifacts(root, entry, value, errors);
|
|
969
|
+
validatePlanAndApproval(root, entry, value, domain.profileMeta, false, errors);
|
|
970
|
+
const attempts = validateAttempts(root, entry, value, domain.profileMeta, false, errors);
|
|
971
|
+
validateCompletion(entry, value, attempts, errors);
|
|
972
|
+
if (isDirectory(activeRoot(stateRoot, entry))) errors.push(`${entryKey(entry)}: archived change still exists active`);
|
|
973
|
+
if (options.stage === "complete" && selected(options, entry)) validatePromotion(stateRoot, root, entry, true, errors);
|
|
974
|
+
}
|
|
975
|
+
if (options.change && ![...index.active, ...index.archived].some((entry) => selected(options, entry))) errors.push(`${options.scope}:${options.project ?? "-"}:${options.change}: change is not indexed`);
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
function selected(options, entry) { return options.scope === entry.scope && (entry.scope === "global" || options.project === entry.project_id) && options.change === entry.change; }
|
|
979
|
+
|
|
980
|
+
function main() {
|
|
981
|
+
const options = parseArgs(process.argv.slice(2));
|
|
982
|
+
if (options.digest) { const errors = []; const value = readJson(options.digest, options.digest, errors); if (errors.length || !value) throw new Error(errors.join("; ")); console.log(canonicalDigest(value)); return; }
|
|
983
|
+
const errors = [];
|
|
984
|
+
if (options.selfCheck) validateWorkflowRoot(resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."), errors);
|
|
985
|
+
if (options.workflowRoot) validateWorkflowRoot(options.workflowRoot, errors);
|
|
986
|
+
if (options.stateRoot) validateStateRoot(options.stateRoot, options, errors);
|
|
987
|
+
if (errors.length) { console.error(`Ops validation failed (${errors.length})`); for (const error of errors) console.error(` - ${error}`); process.exitCode = 1; return; }
|
|
988
|
+
console.log("Ops validation: OK");
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
try { main(); }
|
|
992
|
+
catch (error) { console.error(`Ops validation failed: ${error.message}`); process.exitCode = 1; }
|