@msn-control/liftoff 0.9.9 → 0.10.1
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/DEVELOPER.md +142 -0
- package/README.md +26 -18
- package/assets/governance/single-maintainer-gitflow/policy.md +13 -11
- package/dist/args.js +23 -1
- package/dist/args.js.map +1 -1
- package/dist/artifact-lifecycle.d.ts +1 -1
- package/dist/artifact-lifecycle.js +5 -0
- package/dist/artifact-lifecycle.js.map +1 -1
- package/dist/catalogs.d.ts +2 -0
- package/dist/catalogs.js +13 -1
- package/dist/catalogs.js.map +1 -1
- package/dist/commands.js +365 -17
- package/dist/commands.js.map +1 -1
- package/dist/file-system.js +100 -13
- package/dist/file-system.js.map +1 -1
- package/dist/governance-activation/activation-state.d.ts +32 -0
- package/dist/governance-activation/activation-state.js +165 -0
- package/dist/governance-activation/activation-state.js.map +1 -0
- package/dist/governance-activation/approvals.d.ts +188 -0
- package/dist/governance-activation/approvals.js +508 -0
- package/dist/governance-activation/approvals.js.map +1 -0
- package/dist/governance-activation/canonical-json.d.ts +3 -0
- package/dist/governance-activation/canonical-json.js +36 -0
- package/dist/governance-activation/canonical-json.js.map +1 -0
- package/dist/governance-activation/commands.d.ts +10 -0
- package/dist/governance-activation/commands.js +1023 -0
- package/dist/governance-activation/commands.js.map +1 -0
- package/dist/governance-activation/compatibility.d.ts +68 -0
- package/dist/governance-activation/compatibility.js +447 -0
- package/dist/governance-activation/compatibility.js.map +1 -0
- package/dist/governance-activation/credentials.d.ts +160 -0
- package/dist/governance-activation/credentials.js +403 -0
- package/dist/governance-activation/credentials.js.map +1 -0
- package/dist/governance-activation/doctor.d.ts +10 -0
- package/dist/governance-activation/doctor.js +379 -0
- package/dist/governance-activation/doctor.js.map +1 -0
- package/dist/governance-activation/evidence.d.ts +49 -0
- package/dist/governance-activation/evidence.js +246 -0
- package/dist/governance-activation/evidence.js.map +1 -0
- package/dist/governance-activation/graph.d.ts +397 -0
- package/dist/governance-activation/graph.js +441 -0
- package/dist/governance-activation/graph.js.map +1 -0
- package/dist/governance-activation/identity.d.ts +35 -0
- package/dist/governance-activation/identity.js +89 -0
- package/dist/governance-activation/identity.js.map +1 -0
- package/dist/governance-activation/index.d.ts +18 -0
- package/dist/governance-activation/index.js +19 -0
- package/dist/governance-activation/index.js.map +1 -0
- package/dist/governance-activation/migration.d.ts +44 -0
- package/dist/governance-activation/migration.js +268 -0
- package/dist/governance-activation/migration.js.map +1 -0
- package/dist/governance-activation/readiness.d.ts +23 -0
- package/dist/governance-activation/readiness.js +158 -0
- package/dist/governance-activation/readiness.js.map +1 -0
- package/dist/governance-activation/reconciliation.d.ts +11 -0
- package/dist/governance-activation/reconciliation.js +58 -0
- package/dist/governance-activation/reconciliation.js.map +1 -0
- package/dist/governance-activation/release-integrity.d.ts +11 -0
- package/dist/governance-activation/release-integrity.js +49 -0
- package/dist/governance-activation/release-integrity.js.map +1 -0
- package/dist/governance-activation/seed-lifecycle.d.ts +116 -0
- package/dist/governance-activation/seed-lifecycle.js +609 -0
- package/dist/governance-activation/seed-lifecycle.js.map +1 -0
- package/dist/governance-activation/source-of-truth.d.ts +164 -0
- package/dist/governance-activation/source-of-truth.js +986 -0
- package/dist/governance-activation/source-of-truth.js.map +1 -0
- package/dist/governance-activation/task-projection.d.ts +21 -0
- package/dist/governance-activation/task-projection.js +75 -0
- package/dist/governance-activation/task-projection.js.map +1 -0
- package/dist/governance-activation/transitions.d.ts +163 -0
- package/dist/governance-activation/transitions.js +2128 -0
- package/dist/governance-activation/transitions.js.map +1 -0
- package/dist/governance-activation/type-contract.d.ts +1 -0
- package/dist/governance-activation/type-contract.js +27 -0
- package/dist/governance-activation/type-contract.js.map +1 -0
- package/dist/governance-activation/types.d.ts +390 -0
- package/dist/governance-activation/types.js +108 -0
- package/dist/governance-activation/types.js.map +1 -0
- package/dist/governance-activation/validators.d.ts +17 -0
- package/dist/governance-activation/validators.js +1305 -0
- package/dist/governance-activation/validators.js.map +1 -0
- package/dist/init-filesystem.js +5 -1
- package/dist/init-filesystem.js.map +1 -1
- package/dist/interactive.js +4 -3
- package/dist/interactive.js.map +1 -1
- package/dist/planner.js +5 -5
- package/dist/planner.js.map +1 -1
- package/dist/power-apps-templates.js +14 -1
- package/dist/power-apps-templates.js.map +1 -1
- package/dist/process-runner.d.ts +3 -0
- package/dist/process-runner.js +68 -10
- package/dist/process-runner.js.map +1 -1
- package/dist/repository-governance.d.ts +13 -3
- package/dist/repository-governance.js +357 -51
- package/dist/repository-governance.js.map +1 -1
- package/dist/telemetry/contract.d.ts +1 -1
- package/dist/telemetry/contract.js +6 -0
- package/dist/telemetry/contract.js.map +1 -1
- package/dist/templates.js +320 -25
- package/dist/templates.js.map +1 -1
- package/dist/types.d.ts +15 -7
- package/docs/cli-reference.md +61 -13
- package/docs/configuration-and-manifests.md +43 -16
- package/docs/existing-repositories.md +22 -11
- package/docs/getting-started.md +46 -10
- package/docs/project-structure.md +35 -6
- package/docs/repository-governance.md +200 -245
- package/docs/safety-and-consent.md +24 -2
- package/docs/troubleshooting.md +59 -4
- package/docs/workloads.md +1 -1
- package/package.json +2 -1
|
@@ -0,0 +1,2128 @@
|
|
|
1
|
+
import { readdir, stat } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { NodeCommandRunner } from '../process-runner.js';
|
|
4
|
+
import { applyProjectFileTransaction, readProjectFile, resolveProjectPath, validateArtifactPathParts } from '../file-system.js';
|
|
5
|
+
import { archiveGeneratedSeedForPhase, generatedSeedChangeName, validateGeneratedSeedForPhase, verifyGeneratedSeedBaselineForPhase } from './seed-lifecycle.js';
|
|
6
|
+
import { evaluateApprovalForTransitionPlan, transitionPlanForPhase } from './approvals.js';
|
|
7
|
+
import { canonicalJson, canonicalSha256 } from './canonical-json.js';
|
|
8
|
+
import { credentialPolicyPathParts, detectCredentialLeaks } from './credentials.js';
|
|
9
|
+
import { evidenceHeaderDigest, selectLatestPhaseEvidence } from './evidence.js';
|
|
10
|
+
import { phaseContractDigests } from './graph.js';
|
|
11
|
+
import { buildApprovedPhase0FactsFromState, renderGovernanceChangeWritePlan, stateWithSelectedActiveChange } from './source-of-truth.js';
|
|
12
|
+
import { phaseIds } from './types.js';
|
|
13
|
+
import { validateEvidenceHeader, validateSavedTransitionPlan, validateUserActivationState } from './validators.js';
|
|
14
|
+
import { activationStateContentHash } from './activation-state.js';
|
|
15
|
+
export const governancePlanDirectoryPathParts = ['governance', 'plans'];
|
|
16
|
+
const planLifetimeMs = 15 * 60 * 1000;
|
|
17
|
+
const dayMs = 24 * 60 * 60 * 1000;
|
|
18
|
+
const remoteImportPayloadKind = 'remote-import-verified.v1';
|
|
19
|
+
const workflowSourcePayloadKind = 'workflow-source-ready.v1';
|
|
20
|
+
const greenRedPayloadKind = 'green-red-proof.v1';
|
|
21
|
+
const bootstrapStateDisposedPayloadKind = 'bootstrap-state-disposed.v1';
|
|
22
|
+
const engineProducer = 'liftoff-governance-transition-engine';
|
|
23
|
+
function errorMessage(error) {
|
|
24
|
+
return error instanceof Error ? error.message : String(error);
|
|
25
|
+
}
|
|
26
|
+
function commandSucceeded(result) {
|
|
27
|
+
return result.status === 0 && !result.timedOut && !result.errorCode;
|
|
28
|
+
}
|
|
29
|
+
function commandFailure(result) {
|
|
30
|
+
if (result.timedOut) {
|
|
31
|
+
return `${result.displayCommand} timed out`;
|
|
32
|
+
}
|
|
33
|
+
if (result.errorCode || result.errorMessage) {
|
|
34
|
+
return `${result.displayCommand}: ${[result.errorCode, result.errorMessage].filter(Boolean).join(': ')}`;
|
|
35
|
+
}
|
|
36
|
+
return `${result.displayCommand} exited ${result.status ?? 'unknown'}${result.stderr ? `: ${result.stderr.trim()}` : ''}`;
|
|
37
|
+
}
|
|
38
|
+
function phaseById(graph, phaseId) {
|
|
39
|
+
const phase = graph.phases.find((entry) => entry.id === phaseId);
|
|
40
|
+
if (!phase) {
|
|
41
|
+
throw new Error(`Unknown phase ${phaseId}.`);
|
|
42
|
+
}
|
|
43
|
+
return phase;
|
|
44
|
+
}
|
|
45
|
+
function safeTimestamp(value) {
|
|
46
|
+
return value.replace(/[^0-9A-Za-z]/g, '');
|
|
47
|
+
}
|
|
48
|
+
export function transitionPlanPathParts(plan) {
|
|
49
|
+
return validateArtifactPathParts([
|
|
50
|
+
...governancePlanDirectoryPathParts,
|
|
51
|
+
`${plan.phaseId}-${safeTimestamp(plan.createdAt)}-${plan.planDigest.slice(0, 12)}.json`
|
|
52
|
+
], 'Governance transition plan path');
|
|
53
|
+
}
|
|
54
|
+
function evidencePathParts(evidenceId) {
|
|
55
|
+
return validateArtifactPathParts(['governance', 'evidence', `${evidenceId}.json`], 'Governance evidence path');
|
|
56
|
+
}
|
|
57
|
+
function activationStatePathParts() {
|
|
58
|
+
return validateArtifactPathParts(['governance', 'activation-state.json'], 'Activation state path');
|
|
59
|
+
}
|
|
60
|
+
function cloneState(state) {
|
|
61
|
+
return JSON.parse(JSON.stringify(state));
|
|
62
|
+
}
|
|
63
|
+
function isRecord(value) {
|
|
64
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
65
|
+
}
|
|
66
|
+
function phaseOrder(phaseId) {
|
|
67
|
+
return phaseIds.indexOf(phaseId);
|
|
68
|
+
}
|
|
69
|
+
function transitionDestination(type, identity, extras = {}) {
|
|
70
|
+
return { type, identity, ...extras };
|
|
71
|
+
}
|
|
72
|
+
function operation(input) {
|
|
73
|
+
return input;
|
|
74
|
+
}
|
|
75
|
+
const allowedActionsByPhase = {
|
|
76
|
+
'seed-valid': [
|
|
77
|
+
'openspec.seed.validate',
|
|
78
|
+
'governance.evidence.write',
|
|
79
|
+
'governance.activation-state.write'
|
|
80
|
+
],
|
|
81
|
+
'seed-verified': [
|
|
82
|
+
'openspec.seed.baseline-verify',
|
|
83
|
+
'governance.evidence.write',
|
|
84
|
+
'governance.activation-state.write'
|
|
85
|
+
],
|
|
86
|
+
'seed-archived': [
|
|
87
|
+
'openspec.seed.archive',
|
|
88
|
+
'governance.evidence.write',
|
|
89
|
+
'governance.activation-state.write'
|
|
90
|
+
],
|
|
91
|
+
committed: [
|
|
92
|
+
'git.init',
|
|
93
|
+
'git.add-reviewed',
|
|
94
|
+
'git.commit-reviewed',
|
|
95
|
+
'git.verify-existing-commit',
|
|
96
|
+
'governance.evidence.write',
|
|
97
|
+
'governance.activation-state.write'
|
|
98
|
+
],
|
|
99
|
+
pushed: [
|
|
100
|
+
'git.push-approved-ref',
|
|
101
|
+
'git.verify-existing-push',
|
|
102
|
+
'governance.evidence.write',
|
|
103
|
+
'governance.activation-state.write'
|
|
104
|
+
],
|
|
105
|
+
'phase-0-complete': [
|
|
106
|
+
'github.phase0.discover',
|
|
107
|
+
'azure.phase0.discover',
|
|
108
|
+
'governance.evidence.write',
|
|
109
|
+
'governance.activation-state.write'
|
|
110
|
+
],
|
|
111
|
+
'activation-approved': [
|
|
112
|
+
'openspec.governance.create-change',
|
|
113
|
+
'spec-kit.governance.create-change',
|
|
114
|
+
'governance.activation-state.write'
|
|
115
|
+
],
|
|
116
|
+
'credential-ready': [
|
|
117
|
+
'github.credential.verify-policy',
|
|
118
|
+
'github.credential.enroll-masked',
|
|
119
|
+
'governance.evidence.write',
|
|
120
|
+
'governance.activation-state.write'
|
|
121
|
+
],
|
|
122
|
+
'provider-ready': [
|
|
123
|
+
'azure.provider.ensure-ready',
|
|
124
|
+
'governance.evidence.write',
|
|
125
|
+
'governance.activation-state.write'
|
|
126
|
+
],
|
|
127
|
+
'state-path-selected': [
|
|
128
|
+
'azure.state-path.select',
|
|
129
|
+
'governance.evidence.write',
|
|
130
|
+
'governance.activation-state.write'
|
|
131
|
+
],
|
|
132
|
+
'existing-private-path': [
|
|
133
|
+
'azure.existing-private-path.verify',
|
|
134
|
+
'governance.evidence.write',
|
|
135
|
+
'governance.activation-state.write'
|
|
136
|
+
],
|
|
137
|
+
'bootstrap-local': [
|
|
138
|
+
'azure.bootstrap-local.apply',
|
|
139
|
+
'github.bootstrap-local.configure',
|
|
140
|
+
'governance.evidence.write',
|
|
141
|
+
'governance.activation-state.write'
|
|
142
|
+
],
|
|
143
|
+
'runner-ready': [
|
|
144
|
+
'github.runner.ensure-ready',
|
|
145
|
+
'governance.evidence.write',
|
|
146
|
+
'governance.activation-state.write'
|
|
147
|
+
],
|
|
148
|
+
'private-backend-proof': [
|
|
149
|
+
'github.runner.backend-proof',
|
|
150
|
+
'azure.remote-state.read',
|
|
151
|
+
'governance.evidence.write',
|
|
152
|
+
'governance.activation-state.write'
|
|
153
|
+
],
|
|
154
|
+
'remote-import-verified': [
|
|
155
|
+
'azure.remote-import.verify',
|
|
156
|
+
'governance.evidence.write',
|
|
157
|
+
'governance.activation-state.write'
|
|
158
|
+
],
|
|
159
|
+
'remote-ready': [
|
|
160
|
+
'azure.remote-ready.verify',
|
|
161
|
+
'governance.evidence.write',
|
|
162
|
+
'governance.activation-state.write'
|
|
163
|
+
],
|
|
164
|
+
'application-foundation': [
|
|
165
|
+
'azure.application-foundation.apply',
|
|
166
|
+
'openspec.governance.update',
|
|
167
|
+
'governance.evidence.write',
|
|
168
|
+
'governance.activation-state.write'
|
|
169
|
+
],
|
|
170
|
+
'workflow-source-ready': [
|
|
171
|
+
'local.workflow-source.write',
|
|
172
|
+
'local.ruleset-source.write',
|
|
173
|
+
'governance.evidence.write',
|
|
174
|
+
'governance.activation-state.write'
|
|
175
|
+
],
|
|
176
|
+
'dev-proof': [
|
|
177
|
+
'github.checks.dev-proof',
|
|
178
|
+
'governance.evidence.write',
|
|
179
|
+
'governance.activation-state.write'
|
|
180
|
+
],
|
|
181
|
+
'staging-qualified': [
|
|
182
|
+
'github.checks.staging',
|
|
183
|
+
'azure.staging.readback',
|
|
184
|
+
'governance.evidence.write',
|
|
185
|
+
'governance.activation-state.write'
|
|
186
|
+
],
|
|
187
|
+
'production-rehearsed': [
|
|
188
|
+
'github.checks.production-rehearsal',
|
|
189
|
+
'azure.production-readback',
|
|
190
|
+
'governance.evidence.write',
|
|
191
|
+
'governance.activation-state.write'
|
|
192
|
+
],
|
|
193
|
+
'green-red-proof': [
|
|
194
|
+
'github.checks.green-red-proof',
|
|
195
|
+
'governance.evidence.write',
|
|
196
|
+
'governance.activation-state.write'
|
|
197
|
+
],
|
|
198
|
+
'enforcement-approved': [
|
|
199
|
+
'governance.activation-state.write'
|
|
200
|
+
],
|
|
201
|
+
'rulesets-applied': [
|
|
202
|
+
'github.ruleset.apply',
|
|
203
|
+
'github.ruleset.readback',
|
|
204
|
+
'governance.evidence.write',
|
|
205
|
+
'governance.activation-state.write'
|
|
206
|
+
],
|
|
207
|
+
'live-readback': [
|
|
208
|
+
'github.ruleset.readback',
|
|
209
|
+
'governance.evidence.write',
|
|
210
|
+
'governance.activation-state.write'
|
|
211
|
+
],
|
|
212
|
+
'bootstrap-state-disposed': [
|
|
213
|
+
'local.bootstrap-state.dispose',
|
|
214
|
+
'governance.evidence.write',
|
|
215
|
+
'governance.activation-state.write'
|
|
216
|
+
]
|
|
217
|
+
};
|
|
218
|
+
function isAllowedAction(phaseId, actionId) {
|
|
219
|
+
return allowedActionsByPhase[phaseId].includes(actionId);
|
|
220
|
+
}
|
|
221
|
+
function assertOperationAllowed(phase, op) {
|
|
222
|
+
if (op.phaseId !== phase.id) {
|
|
223
|
+
throw new Error(`Operation ${op.actionId} belongs to ${op.phaseId}, not ${phase.id}.`);
|
|
224
|
+
}
|
|
225
|
+
if (!isAllowedAction(phase.id, op.actionId)) {
|
|
226
|
+
throw new Error(`Operation ${op.actionId} is not allowlisted for phase ${phase.id}.`);
|
|
227
|
+
}
|
|
228
|
+
const allowed = op.remote ? phase.allowedMutations.remote : phase.allowedMutations.local;
|
|
229
|
+
if (!allowed.includes(op.mutationClass)) {
|
|
230
|
+
throw new Error(`Operation ${op.actionId} mutation ${op.mutationClass} is not declared in phase ${phase.id} ${op.remote ? 'remote' : 'local'} mutations.`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
function assertPlanOperationsAllowed(plan, phase) {
|
|
234
|
+
for (const op of plan.operations) {
|
|
235
|
+
assertOperationAllowed(phase, op);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
function evidenceWriteOperation(phase, pathParts) {
|
|
239
|
+
return operation({
|
|
240
|
+
adapter: 'local-evidence',
|
|
241
|
+
actionId: 'governance.evidence.write',
|
|
242
|
+
mutationClass: 'write-evidence',
|
|
243
|
+
phaseId: phase.id,
|
|
244
|
+
inputs: { pathParts },
|
|
245
|
+
destination: transitionDestination('local', pathParts.join('/'), { pathParts }),
|
|
246
|
+
remote: false,
|
|
247
|
+
destructive: false
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
function stateWriteOperation(phase) {
|
|
251
|
+
return operation({
|
|
252
|
+
adapter: 'local-evidence',
|
|
253
|
+
actionId: 'governance.activation-state.write',
|
|
254
|
+
mutationClass: 'write-activation-state',
|
|
255
|
+
phaseId: phase.id,
|
|
256
|
+
inputs: { pathParts: activationStatePathParts() },
|
|
257
|
+
destination: transitionDestination('local', 'governance/activation-state.json', {
|
|
258
|
+
pathParts: activationStatePathParts()
|
|
259
|
+
}),
|
|
260
|
+
remote: false,
|
|
261
|
+
destructive: false
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
async function allProjectFiles(projectRoot) {
|
|
265
|
+
const files = [];
|
|
266
|
+
async function visit(current, relative) {
|
|
267
|
+
const entries = await readdir(current, { withFileTypes: true });
|
|
268
|
+
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name, 'en'))) {
|
|
269
|
+
if (entry.name === '.git' ||
|
|
270
|
+
entry.name === 'node_modules' ||
|
|
271
|
+
entry.name === '.cache' ||
|
|
272
|
+
(relative === '' && entry.name === 'governance')) {
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
const child = path.join(current, entry.name);
|
|
276
|
+
const childRelative = relative ? `${relative}/${entry.name}` : entry.name;
|
|
277
|
+
if (entry.isDirectory()) {
|
|
278
|
+
await visit(child, childRelative);
|
|
279
|
+
}
|
|
280
|
+
else if (entry.isFile()) {
|
|
281
|
+
files.push(childRelative);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
await visit(projectRoot, '');
|
|
286
|
+
return files;
|
|
287
|
+
}
|
|
288
|
+
function parsePorcelainStatus(output) {
|
|
289
|
+
const entries = output.split('\0').filter(Boolean);
|
|
290
|
+
const parsed = [];
|
|
291
|
+
for (const entry of entries) {
|
|
292
|
+
const index = entry.slice(0, 1);
|
|
293
|
+
const worktree = entry.slice(1, 2);
|
|
294
|
+
const rawPath = entry.slice(3);
|
|
295
|
+
const renamed = rawPath.includes('\0') ? rawPath.split('\0').at(-1) : rawPath;
|
|
296
|
+
parsed.push({ index, worktree, path: renamed });
|
|
297
|
+
}
|
|
298
|
+
return parsed.filter((entry) => !entry.path.startsWith('governance/'));
|
|
299
|
+
}
|
|
300
|
+
async function runCommand(runner, command, cwd) {
|
|
301
|
+
return await runner.run(command, { cwd });
|
|
302
|
+
}
|
|
303
|
+
async function runGit(runner, projectRoot, args) {
|
|
304
|
+
return await runCommand(runner, { executable: 'git', args }, projectRoot);
|
|
305
|
+
}
|
|
306
|
+
async function inspectGitRepository(projectRoot, runner) {
|
|
307
|
+
const rootResult = await runGit(runner, projectRoot, ['rev-parse', '--show-toplevel']);
|
|
308
|
+
if (!commandSucceeded(rootResult)) {
|
|
309
|
+
return {
|
|
310
|
+
insideWorkTree: false,
|
|
311
|
+
root: null,
|
|
312
|
+
branch: null,
|
|
313
|
+
head: null,
|
|
314
|
+
upstream: null,
|
|
315
|
+
status: [],
|
|
316
|
+
remotes: [],
|
|
317
|
+
issues: []
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
const root = path.resolve(rootResult.stdout.trim());
|
|
321
|
+
if (root !== path.resolve(projectRoot)) {
|
|
322
|
+
return {
|
|
323
|
+
insideWorkTree: true,
|
|
324
|
+
root,
|
|
325
|
+
branch: null,
|
|
326
|
+
head: null,
|
|
327
|
+
upstream: null,
|
|
328
|
+
status: [],
|
|
329
|
+
remotes: [],
|
|
330
|
+
issues: [`Git repository root ${root} does not match Liftoff project root ${projectRoot}.`]
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
const branchResult = await runGit(runner, projectRoot, ['symbolic-ref', '--quiet', '--short', 'HEAD']);
|
|
334
|
+
const branch = commandSucceeded(branchResult) ? branchResult.stdout.trim() : null;
|
|
335
|
+
const headResult = await runGit(runner, projectRoot, ['rev-parse', '--verify', 'HEAD']);
|
|
336
|
+
const head = commandSucceeded(headResult) ? headResult.stdout.trim() : null;
|
|
337
|
+
const upstreamResult = await runGit(runner, projectRoot, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']);
|
|
338
|
+
const upstream = commandSucceeded(upstreamResult) ? upstreamResult.stdout.trim() : null;
|
|
339
|
+
const statusResult = await runGit(runner, projectRoot, ['status', '--porcelain=v1', '-z', '--untracked-files=all']);
|
|
340
|
+
const status = commandSucceeded(statusResult) ? parsePorcelainStatus(statusResult.stdout) : [];
|
|
341
|
+
const remoteResult = await runGit(runner, projectRoot, ['remote', '-v']);
|
|
342
|
+
const remotes = commandSucceeded(remoteResult) ? parseRemotes(remoteResult.stdout) : [];
|
|
343
|
+
const issues = [];
|
|
344
|
+
if (!branch) {
|
|
345
|
+
issues.push('Detached HEAD or unborn branch cannot be reviewed for deterministic governance commit/push.');
|
|
346
|
+
}
|
|
347
|
+
if (!commandSucceeded(statusResult)) {
|
|
348
|
+
issues.push(commandFailure(statusResult));
|
|
349
|
+
}
|
|
350
|
+
if (status.some((entry) => entry.index === 'D' || entry.worktree === 'D')) {
|
|
351
|
+
issues.push('Unknown file deletion is outside the reviewed initial commit plan.');
|
|
352
|
+
}
|
|
353
|
+
return { insideWorkTree: true, root, branch, head, upstream, status, remotes, issues };
|
|
354
|
+
}
|
|
355
|
+
function parseRemotes(output) {
|
|
356
|
+
const remotes = new Map();
|
|
357
|
+
for (const line of output.split(/\r?\n/u)) {
|
|
358
|
+
const match = line.match(/^(\S+)\s+(\S+)\s+\((fetch|push)\)$/u);
|
|
359
|
+
if (match?.[3] === 'fetch') {
|
|
360
|
+
remotes.set(match[1], match[2]);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return [...remotes.entries()]
|
|
364
|
+
.map(([name, url]) => ({ name, url }))
|
|
365
|
+
.sort((left, right) => left.name.localeCompare(right.name, 'en'));
|
|
366
|
+
}
|
|
367
|
+
function gitStatusDigest(inspection) {
|
|
368
|
+
return canonicalSha256({
|
|
369
|
+
branch: inspection.branch,
|
|
370
|
+
head: inspection.head,
|
|
371
|
+
status: inspection.status,
|
|
372
|
+
remotes: inspection.remotes
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
async function remoteHead(runner, projectRoot, remote, branch) {
|
|
376
|
+
const result = await runGit(runner, projectRoot, ['ls-remote', '--heads', remote, branch]);
|
|
377
|
+
if (!commandSucceeded(result)) {
|
|
378
|
+
throw new Error(commandFailure(result));
|
|
379
|
+
}
|
|
380
|
+
const first = result.stdout.trim().split(/\s+/u)[0];
|
|
381
|
+
return first && /^[a-f0-9]{40}$/u.test(first) ? first : null;
|
|
382
|
+
}
|
|
383
|
+
async function isAncestor(runner, projectRoot, ancestor, descendant) {
|
|
384
|
+
const result = await runGit(runner, projectRoot, ['merge-base', '--is-ancestor', ancestor, descendant]);
|
|
385
|
+
return result.status === 0;
|
|
386
|
+
}
|
|
387
|
+
function defaultBranch(state) {
|
|
388
|
+
return state.repository.defaultBranch || 'main';
|
|
389
|
+
}
|
|
390
|
+
async function gitCommitOperations(inspection, phase, runner) {
|
|
391
|
+
const git = await inspectGitRepository(inspection.projectRoot, runner);
|
|
392
|
+
if (git.issues.length > 0) {
|
|
393
|
+
throw new Error(git.issues.join(' '));
|
|
394
|
+
}
|
|
395
|
+
const branch = defaultBranch(inspection.state);
|
|
396
|
+
if (!git.insideWorkTree) {
|
|
397
|
+
const files = await allProjectFiles(inspection.projectRoot);
|
|
398
|
+
return [
|
|
399
|
+
operation({
|
|
400
|
+
adapter: 'git',
|
|
401
|
+
actionId: 'git.init',
|
|
402
|
+
mutationClass: 'git-commit',
|
|
403
|
+
phaseId: phase.id,
|
|
404
|
+
inputs: { branch },
|
|
405
|
+
destination: transitionDestination('local', '.git'),
|
|
406
|
+
remote: false,
|
|
407
|
+
destructive: false
|
|
408
|
+
}),
|
|
409
|
+
operation({
|
|
410
|
+
adapter: 'git',
|
|
411
|
+
actionId: 'git.add-reviewed',
|
|
412
|
+
mutationClass: 'git-commit',
|
|
413
|
+
phaseId: phase.id,
|
|
414
|
+
inputs: { paths: files, statusDigest: canonicalSha256(files) },
|
|
415
|
+
destination: transitionDestination('local', 'git-index'),
|
|
416
|
+
remote: false,
|
|
417
|
+
destructive: false
|
|
418
|
+
}),
|
|
419
|
+
operation({
|
|
420
|
+
adapter: 'git',
|
|
421
|
+
actionId: 'git.commit-reviewed',
|
|
422
|
+
mutationClass: 'git-commit',
|
|
423
|
+
phaseId: phase.id,
|
|
424
|
+
inputs: {
|
|
425
|
+
message: 'Initial Liftoff baseline',
|
|
426
|
+
branch,
|
|
427
|
+
paths: files
|
|
428
|
+
},
|
|
429
|
+
destination: transitionDestination('local', branch, { ref: branch }),
|
|
430
|
+
remote: false,
|
|
431
|
+
destructive: false
|
|
432
|
+
})
|
|
433
|
+
];
|
|
434
|
+
}
|
|
435
|
+
if (git.branch !== branch) {
|
|
436
|
+
throw new Error(`Expected branch ${branch}, found ${git.branch ?? 'detached'}.`);
|
|
437
|
+
}
|
|
438
|
+
if (git.upstream) {
|
|
439
|
+
const divergence = await runGit(runner, inspection.projectRoot, ['rev-list', '--left-right', '--count', `${git.upstream}...HEAD`]);
|
|
440
|
+
if (!commandSucceeded(divergence)) {
|
|
441
|
+
throw new Error(commandFailure(divergence));
|
|
442
|
+
}
|
|
443
|
+
const [behind, ahead] = divergence.stdout.trim().split(/\s+/u).map((value) => Number.parseInt(value, 10));
|
|
444
|
+
if ((behind ?? 0) > 0 || (ahead ?? 0) > 0) {
|
|
445
|
+
throw new Error(`Branch ${branch} diverges from ${git.upstream}; refusing commit before explicit reconciliation.`);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
if (git.status.length === 0 && git.head) {
|
|
449
|
+
return [
|
|
450
|
+
operation({
|
|
451
|
+
adapter: 'git',
|
|
452
|
+
actionId: 'git.verify-existing-commit',
|
|
453
|
+
mutationClass: 'read-worktree',
|
|
454
|
+
phaseId: phase.id,
|
|
455
|
+
inputs: { head: git.head, branch, statusDigest: gitStatusDigest(git) },
|
|
456
|
+
destination: transitionDestination('local', branch, { ref: branch }),
|
|
457
|
+
remote: false,
|
|
458
|
+
destructive: false
|
|
459
|
+
})
|
|
460
|
+
];
|
|
461
|
+
}
|
|
462
|
+
const paths = git.status.map((entry) => entry.path).sort((left, right) => left.localeCompare(right, 'en'));
|
|
463
|
+
return [
|
|
464
|
+
operation({
|
|
465
|
+
adapter: 'git',
|
|
466
|
+
actionId: 'git.add-reviewed',
|
|
467
|
+
mutationClass: 'git-commit',
|
|
468
|
+
phaseId: phase.id,
|
|
469
|
+
inputs: { paths, statusDigest: gitStatusDigest(git) },
|
|
470
|
+
destination: transitionDestination('local', 'git-index'),
|
|
471
|
+
remote: false,
|
|
472
|
+
destructive: false
|
|
473
|
+
}),
|
|
474
|
+
operation({
|
|
475
|
+
adapter: 'git',
|
|
476
|
+
actionId: 'git.commit-reviewed',
|
|
477
|
+
mutationClass: 'git-commit',
|
|
478
|
+
phaseId: phase.id,
|
|
479
|
+
inputs: {
|
|
480
|
+
message: 'Initial Liftoff baseline',
|
|
481
|
+
branch,
|
|
482
|
+
paths
|
|
483
|
+
},
|
|
484
|
+
destination: transitionDestination('local', branch, { ref: branch }),
|
|
485
|
+
remote: false,
|
|
486
|
+
destructive: false
|
|
487
|
+
})
|
|
488
|
+
];
|
|
489
|
+
}
|
|
490
|
+
async function gitPushOperations(inspection, phase, runner) {
|
|
491
|
+
const git = await inspectGitRepository(inspection.projectRoot, runner);
|
|
492
|
+
if (git.issues.length > 0) {
|
|
493
|
+
throw new Error(git.issues.join(' '));
|
|
494
|
+
}
|
|
495
|
+
if (!git.insideWorkTree || !git.head) {
|
|
496
|
+
throw new Error('A reviewed local commit is required before push.');
|
|
497
|
+
}
|
|
498
|
+
const branch = defaultBranch(inspection.state);
|
|
499
|
+
if (git.branch !== branch) {
|
|
500
|
+
throw new Error(`Expected branch ${branch}, found ${git.branch ?? 'detached'}.`);
|
|
501
|
+
}
|
|
502
|
+
if (git.status.length > 0) {
|
|
503
|
+
throw new Error(`Worktree has unreviewed changes outside governance/plans: ${git.status.map((entry) => entry.path).join(', ')}.`);
|
|
504
|
+
}
|
|
505
|
+
const remoteNames = new Set(git.remotes.map((remote) => remote.name));
|
|
506
|
+
if (!remoteNames.has('origin') || git.remotes.length !== 1) {
|
|
507
|
+
throw new Error(`Expected exactly one approved origin remote; found ${git.remotes.map((remote) => remote.name).join(', ') || 'none'}.`);
|
|
508
|
+
}
|
|
509
|
+
const remoteSha = await remoteHead(runner, inspection.projectRoot, 'origin', branch);
|
|
510
|
+
if (remoteSha && remoteSha !== git.head && !(await isAncestor(runner, inspection.projectRoot, remoteSha, git.head))) {
|
|
511
|
+
throw new Error('Remote branch is not a fast-forward target; force, reset, rebase, and non-fast-forward push are forbidden.');
|
|
512
|
+
}
|
|
513
|
+
return [
|
|
514
|
+
operation({
|
|
515
|
+
adapter: 'git',
|
|
516
|
+
actionId: remoteSha === git.head ? 'git.verify-existing-push' : 'git.push-approved-ref',
|
|
517
|
+
mutationClass: remoteSha === git.head ? 'github-read' : 'git-push',
|
|
518
|
+
phaseId: phase.id,
|
|
519
|
+
inputs: {
|
|
520
|
+
remote: 'origin',
|
|
521
|
+
branch,
|
|
522
|
+
localHead: git.head,
|
|
523
|
+
remoteHead: remoteSha,
|
|
524
|
+
statusDigest: gitStatusDigest(git)
|
|
525
|
+
},
|
|
526
|
+
destination: transitionDestination('repository', inspection.state.repository.name, {
|
|
527
|
+
repository: inspection.state.repository.name,
|
|
528
|
+
ref: `refs/heads/${branch}`
|
|
529
|
+
}),
|
|
530
|
+
remote: true,
|
|
531
|
+
destructive: false
|
|
532
|
+
})
|
|
533
|
+
];
|
|
534
|
+
}
|
|
535
|
+
async function phaseOperations(inspection, phase, runner, createdAt) {
|
|
536
|
+
const statePath = activationStatePathParts();
|
|
537
|
+
const baseEvidencePath = evidencePathParts(`${phase.id}-${safeTimestamp(createdAt)}`);
|
|
538
|
+
const writeOps = (includeEvidence = true) => [
|
|
539
|
+
...(includeEvidence ? [evidenceWriteOperation(phase, baseEvidencePath)] : []),
|
|
540
|
+
stateWriteOperation(phase)
|
|
541
|
+
];
|
|
542
|
+
switch (phase.id) {
|
|
543
|
+
case 'seed-valid':
|
|
544
|
+
return [
|
|
545
|
+
operation({
|
|
546
|
+
adapter: 'selected-spec-workflow',
|
|
547
|
+
actionId: 'openspec.seed.validate',
|
|
548
|
+
mutationClass: 'read-worktree',
|
|
549
|
+
phaseId: phase.id,
|
|
550
|
+
inputs: { changeName: generatedSeedChangeName(inspection.manifest) },
|
|
551
|
+
destination: transitionDestination('local', inspection.projectRoot),
|
|
552
|
+
remote: false,
|
|
553
|
+
destructive: false
|
|
554
|
+
}),
|
|
555
|
+
...writeOps()
|
|
556
|
+
];
|
|
557
|
+
case 'seed-verified':
|
|
558
|
+
return [
|
|
559
|
+
operation({
|
|
560
|
+
adapter: 'selected-spec-workflow',
|
|
561
|
+
actionId: 'openspec.seed.baseline-verify',
|
|
562
|
+
mutationClass: 'read-worktree',
|
|
563
|
+
phaseId: phase.id,
|
|
564
|
+
inputs: { changeName: generatedSeedChangeName(inspection.manifest) },
|
|
565
|
+
destination: transitionDestination('local', inspection.projectRoot),
|
|
566
|
+
remote: false,
|
|
567
|
+
destructive: false
|
|
568
|
+
}),
|
|
569
|
+
...writeOps()
|
|
570
|
+
];
|
|
571
|
+
case 'seed-archived':
|
|
572
|
+
return [
|
|
573
|
+
operation({
|
|
574
|
+
adapter: 'selected-spec-workflow',
|
|
575
|
+
actionId: 'openspec.seed.archive',
|
|
576
|
+
mutationClass: 'write-openspec-seed',
|
|
577
|
+
phaseId: phase.id,
|
|
578
|
+
inputs: { changeName: generatedSeedChangeName(inspection.manifest) },
|
|
579
|
+
destination: transitionDestination('local', 'openspec/changes'),
|
|
580
|
+
remote: false,
|
|
581
|
+
destructive: false
|
|
582
|
+
}),
|
|
583
|
+
...writeOps()
|
|
584
|
+
];
|
|
585
|
+
case 'committed':
|
|
586
|
+
return [...await gitCommitOperations(inspection, phase, runner), ...writeOps()];
|
|
587
|
+
case 'pushed':
|
|
588
|
+
return [...await gitPushOperations(inspection, phase, runner), ...writeOps()];
|
|
589
|
+
case 'phase-0-complete':
|
|
590
|
+
return [
|
|
591
|
+
operation({
|
|
592
|
+
adapter: 'github',
|
|
593
|
+
actionId: 'github.phase0.discover',
|
|
594
|
+
mutationClass: 'github-read',
|
|
595
|
+
phaseId: phase.id,
|
|
596
|
+
inputs: { repository: inspection.state.repository.name },
|
|
597
|
+
destination: transitionDestination('repository', inspection.state.repository.name, {
|
|
598
|
+
repository: inspection.state.repository.name
|
|
599
|
+
}),
|
|
600
|
+
remote: true,
|
|
601
|
+
destructive: false
|
|
602
|
+
}),
|
|
603
|
+
operation({
|
|
604
|
+
adapter: 'azure-opentofu',
|
|
605
|
+
actionId: 'azure.phase0.discover',
|
|
606
|
+
mutationClass: 'azure-read',
|
|
607
|
+
phaseId: phase.id,
|
|
608
|
+
inputs: { cloud: 'cloud' in inspection.manifest.project.workload ? inspection.manifest.project.workload.cloud : 'power-platform' },
|
|
609
|
+
destination: transitionDestination('subscription', 'phase-0-discovered-subscription', {
|
|
610
|
+
subscriptionId: 'phase-0-discovered-subscription'
|
|
611
|
+
}),
|
|
612
|
+
remote: true,
|
|
613
|
+
destructive: false
|
|
614
|
+
}),
|
|
615
|
+
...writeOps()
|
|
616
|
+
];
|
|
617
|
+
case 'activation-approved': {
|
|
618
|
+
const actionId = inspection.manifest.project.specWorkflow === 'openspec'
|
|
619
|
+
? 'openspec.governance.create-change'
|
|
620
|
+
: 'spec-kit.governance.create-change';
|
|
621
|
+
return [
|
|
622
|
+
operation({
|
|
623
|
+
adapter: 'selected-spec-workflow',
|
|
624
|
+
actionId,
|
|
625
|
+
mutationClass: 'write-openspec-governance',
|
|
626
|
+
phaseId: phase.id,
|
|
627
|
+
inputs: { workflowKind: inspection.manifest.project.specWorkflow },
|
|
628
|
+
destination: transitionDestination('local', 'governance change'),
|
|
629
|
+
remote: false,
|
|
630
|
+
destructive: false
|
|
631
|
+
}),
|
|
632
|
+
stateWriteOperation(phase)
|
|
633
|
+
];
|
|
634
|
+
}
|
|
635
|
+
case 'credential-ready':
|
|
636
|
+
return [
|
|
637
|
+
operation({
|
|
638
|
+
adapter: 'github',
|
|
639
|
+
actionId: 'github.credential.verify-policy',
|
|
640
|
+
mutationClass: 'github-read',
|
|
641
|
+
phaseId: phase.id,
|
|
642
|
+
inputs: { policyPathParts: credentialPolicyPathParts },
|
|
643
|
+
destination: transitionDestination('repository', inspection.state.repository.name, {
|
|
644
|
+
repository: inspection.state.repository.name
|
|
645
|
+
}),
|
|
646
|
+
remote: true,
|
|
647
|
+
destructive: false
|
|
648
|
+
}),
|
|
649
|
+
...writeOps()
|
|
650
|
+
];
|
|
651
|
+
case 'provider-ready':
|
|
652
|
+
return [
|
|
653
|
+
operation({
|
|
654
|
+
adapter: 'azure-opentofu',
|
|
655
|
+
actionId: 'azure.provider.ensure-ready',
|
|
656
|
+
mutationClass: 'azure-provider-register',
|
|
657
|
+
phaseId: phase.id,
|
|
658
|
+
inputs: { retainedCapability: true },
|
|
659
|
+
destination: transitionDestination('subscription', 'phase-0-discovered-subscription', {
|
|
660
|
+
subscriptionId: 'phase-0-discovered-subscription'
|
|
661
|
+
}),
|
|
662
|
+
remote: true,
|
|
663
|
+
destructive: false
|
|
664
|
+
}),
|
|
665
|
+
...writeOps()
|
|
666
|
+
];
|
|
667
|
+
case 'state-path-selected':
|
|
668
|
+
return [
|
|
669
|
+
operation({
|
|
670
|
+
adapter: 'azure-opentofu',
|
|
671
|
+
actionId: 'azure.state-path.select',
|
|
672
|
+
mutationClass: 'azure-read',
|
|
673
|
+
phaseId: phase.id,
|
|
674
|
+
inputs: { allowed: ['existing-private', 'bootstrap-local'] },
|
|
675
|
+
destination: transitionDestination('subscription', 'phase-0-discovered-subscription', {
|
|
676
|
+
subscriptionId: 'phase-0-discovered-subscription'
|
|
677
|
+
}),
|
|
678
|
+
remote: true,
|
|
679
|
+
destructive: false
|
|
680
|
+
}),
|
|
681
|
+
...writeOps()
|
|
682
|
+
];
|
|
683
|
+
case 'existing-private-path':
|
|
684
|
+
return [
|
|
685
|
+
operation({
|
|
686
|
+
adapter: 'azure-opentofu',
|
|
687
|
+
actionId: 'azure.existing-private-path.verify',
|
|
688
|
+
mutationClass: 'azure-read',
|
|
689
|
+
phaseId: phase.id,
|
|
690
|
+
inputs: { statePath: 'existing-private' },
|
|
691
|
+
destination: transitionDestination('subscription', 'phase-0-discovered-subscription', {
|
|
692
|
+
subscriptionId: 'phase-0-discovered-subscription'
|
|
693
|
+
}),
|
|
694
|
+
remote: true,
|
|
695
|
+
destructive: false
|
|
696
|
+
}),
|
|
697
|
+
...writeOps()
|
|
698
|
+
];
|
|
699
|
+
case 'bootstrap-local':
|
|
700
|
+
return [
|
|
701
|
+
operation({
|
|
702
|
+
adapter: 'azure-opentofu',
|
|
703
|
+
actionId: 'azure.bootstrap-local.apply',
|
|
704
|
+
mutationClass: 'azure-network-provision',
|
|
705
|
+
phaseId: phase.id,
|
|
706
|
+
inputs: { boundedLocalBootstrap: true },
|
|
707
|
+
destination: transitionDestination('subscription', 'phase-0-discovered-subscription', {
|
|
708
|
+
subscriptionId: 'phase-0-discovered-subscription'
|
|
709
|
+
}),
|
|
710
|
+
remote: true,
|
|
711
|
+
destructive: false
|
|
712
|
+
}),
|
|
713
|
+
operation({
|
|
714
|
+
adapter: 'github',
|
|
715
|
+
actionId: 'github.bootstrap-local.configure',
|
|
716
|
+
mutationClass: 'github-write',
|
|
717
|
+
phaseId: phase.id,
|
|
718
|
+
inputs: { repository: inspection.state.repository.name },
|
|
719
|
+
destination: transitionDestination('repository', inspection.state.repository.name, {
|
|
720
|
+
repository: inspection.state.repository.name
|
|
721
|
+
}),
|
|
722
|
+
remote: true,
|
|
723
|
+
destructive: false
|
|
724
|
+
}),
|
|
725
|
+
...writeOps()
|
|
726
|
+
];
|
|
727
|
+
case 'runner-ready':
|
|
728
|
+
return [
|
|
729
|
+
operation({
|
|
730
|
+
adapter: 'github',
|
|
731
|
+
actionId: 'github.runner.ensure-ready',
|
|
732
|
+
mutationClass: 'github-write',
|
|
733
|
+
phaseId: phase.id,
|
|
734
|
+
inputs: { restrictedRunner: true },
|
|
735
|
+
destination: transitionDestination('repository', inspection.state.repository.name, {
|
|
736
|
+
repository: inspection.state.repository.name
|
|
737
|
+
}),
|
|
738
|
+
remote: true,
|
|
739
|
+
destructive: false
|
|
740
|
+
}),
|
|
741
|
+
...writeOps()
|
|
742
|
+
];
|
|
743
|
+
case 'private-backend-proof':
|
|
744
|
+
return [
|
|
745
|
+
operation({
|
|
746
|
+
adapter: 'github',
|
|
747
|
+
actionId: 'github.runner.backend-proof',
|
|
748
|
+
mutationClass: 'github-read',
|
|
749
|
+
phaseId: phase.id,
|
|
750
|
+
inputs: { requiredConclusion: 'success' },
|
|
751
|
+
destination: transitionDestination('repository', inspection.state.repository.name, {
|
|
752
|
+
repository: inspection.state.repository.name
|
|
753
|
+
}),
|
|
754
|
+
remote: true,
|
|
755
|
+
destructive: false
|
|
756
|
+
}),
|
|
757
|
+
...writeOps()
|
|
758
|
+
];
|
|
759
|
+
case 'remote-import-verified':
|
|
760
|
+
return [
|
|
761
|
+
operation({
|
|
762
|
+
adapter: 'azure-opentofu',
|
|
763
|
+
actionId: 'azure.remote-import.verify',
|
|
764
|
+
mutationClass: 'azure-state-import',
|
|
765
|
+
phaseId: phase.id,
|
|
766
|
+
inputs: { noChangePlanRequired: true },
|
|
767
|
+
destination: transitionDestination('subscription', 'phase-0-discovered-subscription', {
|
|
768
|
+
subscriptionId: 'phase-0-discovered-subscription'
|
|
769
|
+
}),
|
|
770
|
+
remote: true,
|
|
771
|
+
destructive: false
|
|
772
|
+
}),
|
|
773
|
+
...writeOps()
|
|
774
|
+
];
|
|
775
|
+
case 'remote-ready':
|
|
776
|
+
return [
|
|
777
|
+
operation({
|
|
778
|
+
adapter: 'azure-opentofu',
|
|
779
|
+
actionId: 'azure.remote-ready.verify',
|
|
780
|
+
mutationClass: 'azure-read',
|
|
781
|
+
phaseId: phase.id,
|
|
782
|
+
inputs: { retainBootstrapStateForDays: 30 },
|
|
783
|
+
destination: transitionDestination('subscription', 'phase-0-discovered-subscription', {
|
|
784
|
+
subscriptionId: 'phase-0-discovered-subscription'
|
|
785
|
+
}),
|
|
786
|
+
remote: true,
|
|
787
|
+
destructive: false
|
|
788
|
+
}),
|
|
789
|
+
...writeOps()
|
|
790
|
+
];
|
|
791
|
+
case 'application-foundation':
|
|
792
|
+
return [
|
|
793
|
+
operation({
|
|
794
|
+
adapter: 'azure-opentofu',
|
|
795
|
+
actionId: 'azure.application-foundation.apply',
|
|
796
|
+
mutationClass: 'azure-resource-provision',
|
|
797
|
+
phaseId: phase.id,
|
|
798
|
+
inputs: { noProviderUnregisterRollback: true },
|
|
799
|
+
destination: transitionDestination('subscription', 'phase-0-discovered-subscription', {
|
|
800
|
+
subscriptionId: 'phase-0-discovered-subscription'
|
|
801
|
+
}),
|
|
802
|
+
remote: true,
|
|
803
|
+
destructive: false
|
|
804
|
+
}),
|
|
805
|
+
...writeOps()
|
|
806
|
+
];
|
|
807
|
+
case 'workflow-source-ready':
|
|
808
|
+
return [
|
|
809
|
+
operation({
|
|
810
|
+
adapter: 'local-state',
|
|
811
|
+
actionId: 'local.workflow-source.write',
|
|
812
|
+
mutationClass: 'write-workflows',
|
|
813
|
+
phaseId: phase.id,
|
|
814
|
+
inputs: { path: '.github/workflows' },
|
|
815
|
+
destination: transitionDestination('local', '.github/workflows', {
|
|
816
|
+
pathParts: ['.github', 'workflows']
|
|
817
|
+
}),
|
|
818
|
+
remote: false,
|
|
819
|
+
destructive: false
|
|
820
|
+
}),
|
|
821
|
+
operation({
|
|
822
|
+
adapter: 'local-state',
|
|
823
|
+
actionId: 'local.ruleset-source.write',
|
|
824
|
+
mutationClass: 'write-ruleset-source',
|
|
825
|
+
phaseId: phase.id,
|
|
826
|
+
inputs: { path: '.github/rulesets' },
|
|
827
|
+
destination: transitionDestination('local', '.github/rulesets', {
|
|
828
|
+
pathParts: ['.github', 'rulesets']
|
|
829
|
+
}),
|
|
830
|
+
remote: false,
|
|
831
|
+
destructive: false
|
|
832
|
+
}),
|
|
833
|
+
...writeOps()
|
|
834
|
+
];
|
|
835
|
+
case 'dev-proof':
|
|
836
|
+
return [
|
|
837
|
+
operation({
|
|
838
|
+
adapter: 'github',
|
|
839
|
+
actionId: 'github.checks.dev-proof',
|
|
840
|
+
mutationClass: 'github-read',
|
|
841
|
+
phaseId: phase.id,
|
|
842
|
+
inputs: { acceptedConclusions: ['success'] },
|
|
843
|
+
destination: transitionDestination('repository', inspection.state.repository.name, {
|
|
844
|
+
repository: inspection.state.repository.name
|
|
845
|
+
}),
|
|
846
|
+
remote: true,
|
|
847
|
+
destructive: false
|
|
848
|
+
}),
|
|
849
|
+
...writeOps()
|
|
850
|
+
];
|
|
851
|
+
case 'staging-qualified':
|
|
852
|
+
return [
|
|
853
|
+
operation({
|
|
854
|
+
adapter: 'github',
|
|
855
|
+
actionId: 'github.checks.staging',
|
|
856
|
+
mutationClass: 'github-read',
|
|
857
|
+
phaseId: phase.id,
|
|
858
|
+
inputs: { acceptedConclusions: ['success'] },
|
|
859
|
+
destination: transitionDestination('repository', inspection.state.repository.name, {
|
|
860
|
+
repository: inspection.state.repository.name
|
|
861
|
+
}),
|
|
862
|
+
remote: true,
|
|
863
|
+
destructive: false
|
|
864
|
+
}),
|
|
865
|
+
...writeOps()
|
|
866
|
+
];
|
|
867
|
+
case 'production-rehearsed':
|
|
868
|
+
return [
|
|
869
|
+
operation({
|
|
870
|
+
adapter: 'github',
|
|
871
|
+
actionId: 'github.checks.production-rehearsal',
|
|
872
|
+
mutationClass: 'github-read',
|
|
873
|
+
phaseId: phase.id,
|
|
874
|
+
inputs: { acceptedConclusions: ['success'] },
|
|
875
|
+
destination: transitionDestination('repository', inspection.state.repository.name, {
|
|
876
|
+
repository: inspection.state.repository.name
|
|
877
|
+
}),
|
|
878
|
+
remote: true,
|
|
879
|
+
destructive: false
|
|
880
|
+
}),
|
|
881
|
+
...writeOps()
|
|
882
|
+
];
|
|
883
|
+
case 'green-red-proof':
|
|
884
|
+
return [
|
|
885
|
+
operation({
|
|
886
|
+
adapter: 'github',
|
|
887
|
+
actionId: 'github.checks.green-red-proof',
|
|
888
|
+
mutationClass: 'github-read',
|
|
889
|
+
phaseId: phase.id,
|
|
890
|
+
inputs: { required: ['green-success', 'deliberate-red-failure'] },
|
|
891
|
+
destination: transitionDestination('repository', inspection.state.repository.name, {
|
|
892
|
+
repository: inspection.state.repository.name
|
|
893
|
+
}),
|
|
894
|
+
remote: true,
|
|
895
|
+
destructive: false
|
|
896
|
+
}),
|
|
897
|
+
...writeOps()
|
|
898
|
+
];
|
|
899
|
+
case 'enforcement-approved':
|
|
900
|
+
return [stateWriteOperation(phase)];
|
|
901
|
+
case 'rulesets-applied':
|
|
902
|
+
return [
|
|
903
|
+
operation({
|
|
904
|
+
adapter: 'github',
|
|
905
|
+
actionId: 'github.ruleset.apply',
|
|
906
|
+
mutationClass: 'github-ruleset-write',
|
|
907
|
+
phaseId: phase.id,
|
|
908
|
+
inputs: { sourceDigest: rulesetSourceDigestFromEvidence(inspection) ?? 'missing' },
|
|
909
|
+
destination: transitionDestination('repository', inspection.state.repository.name, {
|
|
910
|
+
repository: inspection.state.repository.name
|
|
911
|
+
}),
|
|
912
|
+
remote: true,
|
|
913
|
+
destructive: false
|
|
914
|
+
}),
|
|
915
|
+
operation({
|
|
916
|
+
adapter: 'github',
|
|
917
|
+
actionId: 'github.ruleset.readback',
|
|
918
|
+
mutationClass: 'github-read',
|
|
919
|
+
phaseId: phase.id,
|
|
920
|
+
inputs: { sourceDigest: rulesetSourceDigestFromEvidence(inspection) ?? 'missing' },
|
|
921
|
+
destination: transitionDestination('repository', inspection.state.repository.name, {
|
|
922
|
+
repository: inspection.state.repository.name
|
|
923
|
+
}),
|
|
924
|
+
remote: true,
|
|
925
|
+
destructive: false
|
|
926
|
+
}),
|
|
927
|
+
...writeOps()
|
|
928
|
+
];
|
|
929
|
+
case 'live-readback':
|
|
930
|
+
return [
|
|
931
|
+
operation({
|
|
932
|
+
adapter: 'github',
|
|
933
|
+
actionId: 'github.ruleset.readback',
|
|
934
|
+
mutationClass: 'github-read',
|
|
935
|
+
phaseId: phase.id,
|
|
936
|
+
inputs: { sourceDigest: rulesetSourceDigestFromEvidence(inspection) ?? 'missing' },
|
|
937
|
+
destination: transitionDestination('repository', inspection.state.repository.name, {
|
|
938
|
+
repository: inspection.state.repository.name
|
|
939
|
+
}),
|
|
940
|
+
remote: true,
|
|
941
|
+
destructive: false
|
|
942
|
+
}),
|
|
943
|
+
...writeOps()
|
|
944
|
+
];
|
|
945
|
+
case 'bootstrap-state-disposed': {
|
|
946
|
+
const paths = disposalPathInputs(inspection.state.bootstrapState);
|
|
947
|
+
return [
|
|
948
|
+
operation({
|
|
949
|
+
adapter: 'local-state',
|
|
950
|
+
actionId: 'local.bootstrap-state.dispose',
|
|
951
|
+
mutationClass: 'delete-local-state',
|
|
952
|
+
phaseId: phase.id,
|
|
953
|
+
inputs: paths,
|
|
954
|
+
destination: transitionDestination('local', 'bootstrap-state', {
|
|
955
|
+
pathParts: statePath
|
|
956
|
+
}),
|
|
957
|
+
remote: false,
|
|
958
|
+
destructive: true
|
|
959
|
+
}),
|
|
960
|
+
...writeOps()
|
|
961
|
+
];
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
function disposalPathInputs(retention) {
|
|
966
|
+
return {
|
|
967
|
+
encryptedStatePathParts: retention?.encryptedStatePathParts ?? [],
|
|
968
|
+
encryptionKeyPathParts: retention?.encryptionKeyPathParts ?? []
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
function rulesetSourceDigestFromEvidence(inspection) {
|
|
972
|
+
const record = latestRecordWithPayload(inspection, 'workflow-source-ready');
|
|
973
|
+
if (!record || !isRecord(record.payload)) {
|
|
974
|
+
return null;
|
|
975
|
+
}
|
|
976
|
+
if (record.payload.kind !== workflowSourcePayloadKind) {
|
|
977
|
+
return null;
|
|
978
|
+
}
|
|
979
|
+
const digest = record.payload.rulesetSourceDigest;
|
|
980
|
+
return typeof digest === 'string' && /^[a-f0-9]{64}$/u.test(digest) ? digest : null;
|
|
981
|
+
}
|
|
982
|
+
function latestRecordWithPayload(inspection, phaseId) {
|
|
983
|
+
const records = inspection.evidence.filter((record) => record.header.phaseId === phaseId);
|
|
984
|
+
const selection = selectLatestPhaseEvidence(records, inspection.contexts[phaseId]);
|
|
985
|
+
if (!selection.selected) {
|
|
986
|
+
return null;
|
|
987
|
+
}
|
|
988
|
+
return records.find((record) => record.evidenceId === selection.selected?.evidenceId) ?? null;
|
|
989
|
+
}
|
|
990
|
+
function rollbackPlanForPhase(phase, completed = []) {
|
|
991
|
+
return rollbackPlanFromCompletedOperations(phase.id, phase.rollback.kind, phase.rollback.target, completed);
|
|
992
|
+
}
|
|
993
|
+
export function rollbackPlanFromCompletedOperations(phaseId, strategy, target, completed) {
|
|
994
|
+
const retained = [];
|
|
995
|
+
const cleanupWarnings = [];
|
|
996
|
+
const operations = [];
|
|
997
|
+
for (const op of [...completed].reverse()) {
|
|
998
|
+
if (op.mutationClass === 'azure-provider-register') {
|
|
999
|
+
retained.push(`${op.phaseId}:${op.actionId}:provider-registration`);
|
|
1000
|
+
continue;
|
|
1001
|
+
}
|
|
1002
|
+
if (op.actionId.toLowerCase().includes('unregister')) {
|
|
1003
|
+
cleanupWarnings.push(`Refused to generate provider unregister rollback for ${op.actionId}.`);
|
|
1004
|
+
continue;
|
|
1005
|
+
}
|
|
1006
|
+
if (op.mutationClass === 'github-ruleset-write') {
|
|
1007
|
+
operations.push({
|
|
1008
|
+
adapter: 'github',
|
|
1009
|
+
actionId: 'github.ruleset.disable',
|
|
1010
|
+
mutationClass: 'github-ruleset-write',
|
|
1011
|
+
phaseId,
|
|
1012
|
+
inputs: { fromOperation: op.actionId, cannotExpandScope: true },
|
|
1013
|
+
destination: op.destination,
|
|
1014
|
+
remote: true,
|
|
1015
|
+
destructive: false
|
|
1016
|
+
});
|
|
1017
|
+
continue;
|
|
1018
|
+
}
|
|
1019
|
+
if (op.mutationClass === 'azure-network-provision' || op.mutationClass === 'azure-resource-provision') {
|
|
1020
|
+
operations.push({
|
|
1021
|
+
adapter: 'azure-opentofu',
|
|
1022
|
+
actionId: 'azure.resource.cleanup',
|
|
1023
|
+
mutationClass: op.mutationClass,
|
|
1024
|
+
phaseId,
|
|
1025
|
+
inputs: { fromOperation: op.actionId, noProviderUnregister: true },
|
|
1026
|
+
destination: op.destination,
|
|
1027
|
+
remote: true,
|
|
1028
|
+
destructive: true
|
|
1029
|
+
});
|
|
1030
|
+
continue;
|
|
1031
|
+
}
|
|
1032
|
+
if (op.mutationClass === 'write-local-state' || op.mutationClass === 'delete-local-state') {
|
|
1033
|
+
operations.push({
|
|
1034
|
+
adapter: 'local-state',
|
|
1035
|
+
actionId: 'local.state.rollback',
|
|
1036
|
+
mutationClass: op.mutationClass,
|
|
1037
|
+
phaseId,
|
|
1038
|
+
inputs: { fromOperation: op.actionId },
|
|
1039
|
+
destination: op.destination,
|
|
1040
|
+
remote: false,
|
|
1041
|
+
destructive: op.mutationClass === 'delete-local-state'
|
|
1042
|
+
});
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
return {
|
|
1046
|
+
phaseId,
|
|
1047
|
+
strategy,
|
|
1048
|
+
target,
|
|
1049
|
+
operations,
|
|
1050
|
+
retained,
|
|
1051
|
+
cleanupWarnings
|
|
1052
|
+
};
|
|
1053
|
+
}
|
|
1054
|
+
function planDigestFor(input) {
|
|
1055
|
+
return canonicalSha256({
|
|
1056
|
+
phaseId: input.phase.id,
|
|
1057
|
+
transitionDigest: input.transitionDigest,
|
|
1058
|
+
approvalPlanDigest: input.approvalPlanDigest,
|
|
1059
|
+
operations: input.operations
|
|
1060
|
+
});
|
|
1061
|
+
}
|
|
1062
|
+
function stateHash(inspection) {
|
|
1063
|
+
return inspection.loadedState?.contentHash ?? null;
|
|
1064
|
+
}
|
|
1065
|
+
function assertNoSecrets(plan) {
|
|
1066
|
+
const scan = detectCredentialLeaks([{
|
|
1067
|
+
source: 'generated-artifact',
|
|
1068
|
+
label: 'governance transition plan',
|
|
1069
|
+
text: canonicalJson(plan)
|
|
1070
|
+
}]);
|
|
1071
|
+
if (scan.status === 'compromised') {
|
|
1072
|
+
throw new Error(`Governance transition plan contains credential-shaped content: ${scan.leaks.map((leak) => leak.pattern).join(', ')}.`);
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
export async function buildSavedTransitionPlan(input) {
|
|
1076
|
+
const phaseId = input.inspection.readiness.nextReadyPhase;
|
|
1077
|
+
if (!phaseId) {
|
|
1078
|
+
return null;
|
|
1079
|
+
}
|
|
1080
|
+
const runner = input.runner ?? new NodeCommandRunner();
|
|
1081
|
+
const phase = phaseById(input.inspection.graph, phaseId);
|
|
1082
|
+
const createdAt = (input.now ?? new Date()).toISOString();
|
|
1083
|
+
const expiresAt = new Date(Date.parse(createdAt) + planLifetimeMs).toISOString();
|
|
1084
|
+
const context = input.inspection.contexts[phaseId];
|
|
1085
|
+
const approvalPlan = transitionPlanForPhase(phase, input.inspection.state, context.transition, input.inspection.projectRoot);
|
|
1086
|
+
const evaluation = evaluateApprovalForTransitionPlan(approvalPlan, input.inspection.approvals, {
|
|
1087
|
+
now: input.now
|
|
1088
|
+
});
|
|
1089
|
+
const operations = await phaseOperations(input.inspection, phase, runner, createdAt);
|
|
1090
|
+
const digest = planDigestFor({
|
|
1091
|
+
phase,
|
|
1092
|
+
transitionDigest: context.transition.transitionDigest,
|
|
1093
|
+
operations,
|
|
1094
|
+
approvalPlanDigest: approvalPlan.planDigest
|
|
1095
|
+
});
|
|
1096
|
+
const plan = validateSavedTransitionPlan({
|
|
1097
|
+
schemaVersion: 1,
|
|
1098
|
+
phaseId,
|
|
1099
|
+
createdAt,
|
|
1100
|
+
expiresAt,
|
|
1101
|
+
identity: input.inspection.state.identity,
|
|
1102
|
+
graphHash: input.inspection.graphHash,
|
|
1103
|
+
stateHash: stateHash(input.inspection),
|
|
1104
|
+
baselineDigest: context.baselineSha,
|
|
1105
|
+
inputDigest: context.inputDigest,
|
|
1106
|
+
transitionDigest: context.transition.transitionDigest,
|
|
1107
|
+
planDigest: digest,
|
|
1108
|
+
mutationClasses: phase.allowedMutations,
|
|
1109
|
+
operations,
|
|
1110
|
+
approval: {
|
|
1111
|
+
gateKind: phase.approvalGate.kind,
|
|
1112
|
+
required: phase.approvalGate.required,
|
|
1113
|
+
evaluation,
|
|
1114
|
+
envelopeId: evaluation.envelopeId,
|
|
1115
|
+
envelopeHash: evaluation.envelopeHash
|
|
1116
|
+
},
|
|
1117
|
+
rollbackPlan: rollbackPlanForPhase(phase),
|
|
1118
|
+
noSecrets: true
|
|
1119
|
+
});
|
|
1120
|
+
assertPlanOperationsAllowed(plan, phase);
|
|
1121
|
+
assertNoSecrets(plan);
|
|
1122
|
+
return plan;
|
|
1123
|
+
}
|
|
1124
|
+
export async function previewApplyNext(input) {
|
|
1125
|
+
let plan = null;
|
|
1126
|
+
const blockers = [];
|
|
1127
|
+
try {
|
|
1128
|
+
plan = await buildSavedTransitionPlan(input);
|
|
1129
|
+
}
|
|
1130
|
+
catch (error) {
|
|
1131
|
+
blockers.push(errorMessage(error));
|
|
1132
|
+
}
|
|
1133
|
+
const nextReadyPhase = input.inspection.readiness.nextReadyPhase;
|
|
1134
|
+
if (!nextReadyPhase || blockers.length > 0 || !plan) {
|
|
1135
|
+
return {
|
|
1136
|
+
schemaVersion: 1,
|
|
1137
|
+
command: 'governance apply-next',
|
|
1138
|
+
projectRoot: input.inspection.projectRoot,
|
|
1139
|
+
execute: input.execute,
|
|
1140
|
+
applied: false,
|
|
1141
|
+
authorized: false,
|
|
1142
|
+
reason: 'blocked',
|
|
1143
|
+
message: blockers[0] ?? 'No phase is ready for execution.',
|
|
1144
|
+
nextReadyPhase,
|
|
1145
|
+
approval: plan?.approval ?? null,
|
|
1146
|
+
proposedMutations: {
|
|
1147
|
+
local: plan?.mutationClasses.local ?? ['none'],
|
|
1148
|
+
remote: plan?.mutationClasses.remote ?? ['none'],
|
|
1149
|
+
operations: plan?.operations ?? []
|
|
1150
|
+
},
|
|
1151
|
+
savedPlan: null,
|
|
1152
|
+
noWrites: true,
|
|
1153
|
+
blockers: blockers.length > 0 ? blockers : ['No phase is ready for execution.']
|
|
1154
|
+
};
|
|
1155
|
+
}
|
|
1156
|
+
if (plan.approval.evaluation.approvalRequired) {
|
|
1157
|
+
return {
|
|
1158
|
+
schemaVersion: 1,
|
|
1159
|
+
command: 'governance apply-next',
|
|
1160
|
+
projectRoot: input.inspection.projectRoot,
|
|
1161
|
+
execute: input.execute,
|
|
1162
|
+
applied: false,
|
|
1163
|
+
authorized: false,
|
|
1164
|
+
reason: 'approval-required',
|
|
1165
|
+
message: plan.approval.evaluation.reasons.join('; '),
|
|
1166
|
+
nextReadyPhase,
|
|
1167
|
+
approval: plan.approval,
|
|
1168
|
+
proposedMutations: {
|
|
1169
|
+
local: plan.mutationClasses.local,
|
|
1170
|
+
remote: plan.mutationClasses.remote,
|
|
1171
|
+
operations: plan.operations
|
|
1172
|
+
},
|
|
1173
|
+
savedPlan: null,
|
|
1174
|
+
noWrites: true,
|
|
1175
|
+
blockers: plan.approval.evaluation.reasons
|
|
1176
|
+
};
|
|
1177
|
+
}
|
|
1178
|
+
return {
|
|
1179
|
+
schemaVersion: 1,
|
|
1180
|
+
command: 'governance apply-next',
|
|
1181
|
+
projectRoot: input.inspection.projectRoot,
|
|
1182
|
+
execute: input.execute,
|
|
1183
|
+
applied: false,
|
|
1184
|
+
authorized: true,
|
|
1185
|
+
reason: input.execute ? 'execute-requested' : 'execute-required',
|
|
1186
|
+
message: input.execute
|
|
1187
|
+
? 'Execution requested; the plan must be saved and revalidated before any mutation.'
|
|
1188
|
+
: 'Preview only. Rerun with --execute to save this plan and execute at most one phase.',
|
|
1189
|
+
nextReadyPhase,
|
|
1190
|
+
approval: plan.approval,
|
|
1191
|
+
proposedMutations: {
|
|
1192
|
+
local: plan.mutationClasses.local,
|
|
1193
|
+
remote: plan.mutationClasses.remote,
|
|
1194
|
+
operations: plan.operations
|
|
1195
|
+
},
|
|
1196
|
+
savedPlan: null,
|
|
1197
|
+
noWrites: true,
|
|
1198
|
+
blockers: []
|
|
1199
|
+
};
|
|
1200
|
+
}
|
|
1201
|
+
async function saveTransitionPlan(projectRoot, plan) {
|
|
1202
|
+
const validated = validateSavedTransitionPlan(plan);
|
|
1203
|
+
assertNoSecrets(validated);
|
|
1204
|
+
const pathParts = transitionPlanPathParts(validated);
|
|
1205
|
+
const existing = await readProjectFile(projectRoot, pathParts);
|
|
1206
|
+
if (existing !== undefined) {
|
|
1207
|
+
throw new Error(`Refusing to overwrite existing governance transition plan ${pathParts.join('/')}.`);
|
|
1208
|
+
}
|
|
1209
|
+
const content = `${canonicalJson(validated)}\n`;
|
|
1210
|
+
await applyProjectFileTransaction(projectRoot, [{
|
|
1211
|
+
type: 'write',
|
|
1212
|
+
pathParts,
|
|
1213
|
+
content
|
|
1214
|
+
}]);
|
|
1215
|
+
return { pathParts, digest: canonicalSha256(validated) };
|
|
1216
|
+
}
|
|
1217
|
+
function comparePlanFreshness(saved, fresh) {
|
|
1218
|
+
if (!fresh) {
|
|
1219
|
+
return ['No phase remained ready after saving the transition plan.'];
|
|
1220
|
+
}
|
|
1221
|
+
const issues = [];
|
|
1222
|
+
if (saved.phaseId !== fresh.phaseId) {
|
|
1223
|
+
issues.push(`Ready phase changed from ${saved.phaseId} to ${fresh.phaseId}.`);
|
|
1224
|
+
}
|
|
1225
|
+
for (const field of ['stateHash', 'baselineDigest', 'inputDigest', 'transitionDigest']) {
|
|
1226
|
+
if (saved[field] !== fresh[field]) {
|
|
1227
|
+
issues.push(`${field} changed after plan save.`);
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
if (canonicalSha256(saved.operations) !== canonicalSha256(fresh.operations)) {
|
|
1231
|
+
issues.push('Proposed operations changed after plan save.');
|
|
1232
|
+
}
|
|
1233
|
+
if (saved.approval.evaluation.approvalRequired || fresh.approval.evaluation.approvalRequired) {
|
|
1234
|
+
issues.push('Approval was not valid immediately before execution.');
|
|
1235
|
+
}
|
|
1236
|
+
return issues;
|
|
1237
|
+
}
|
|
1238
|
+
function evidenceHeaderFor(input) {
|
|
1239
|
+
const context = input.inspection.contexts[input.phase.id];
|
|
1240
|
+
return validateEvidenceHeader({
|
|
1241
|
+
schemaVersion: input.inspection.state.identity.evidenceHeaderSchemaVersion,
|
|
1242
|
+
repositoryId: input.inspection.state.repository.id,
|
|
1243
|
+
identity: input.inspection.state.identity,
|
|
1244
|
+
phaseGraphHash: input.inspection.state.identity.phaseGraphHash,
|
|
1245
|
+
phaseId: input.phase.id,
|
|
1246
|
+
phaseContractDigest: phaseContractDigests(input.inspection.graph)[input.phase.id],
|
|
1247
|
+
inputDigest: input.plan.inputDigest,
|
|
1248
|
+
baselineSha: input.plan.baselineDigest,
|
|
1249
|
+
transition: context.transition,
|
|
1250
|
+
producedAt: input.now.toISOString(),
|
|
1251
|
+
producer: engineProducer,
|
|
1252
|
+
result: input.result
|
|
1253
|
+
});
|
|
1254
|
+
}
|
|
1255
|
+
function appendUnique(values, value) {
|
|
1256
|
+
if (value === null || values.includes(value)) {
|
|
1257
|
+
return [...values];
|
|
1258
|
+
}
|
|
1259
|
+
return [...values, value];
|
|
1260
|
+
}
|
|
1261
|
+
function nextStateForOutcome(input) {
|
|
1262
|
+
const base = cloneState(input.override ?? input.inspection.state);
|
|
1263
|
+
if (input.inspection.sourceOfTruth.status === 'selected' &&
|
|
1264
|
+
input.inspection.sourceOfTruth.recordActiveChangeOnNextMutation) {
|
|
1265
|
+
const selected = stateWithSelectedActiveChange(base, input.inspection.sourceOfTruth.selected);
|
|
1266
|
+
base.activeChange = selected.activeChange;
|
|
1267
|
+
}
|
|
1268
|
+
const phaseState = input.resultState === 'approved' ? 'approved' : input.resultState;
|
|
1269
|
+
base.phases[input.phase.id] = {
|
|
1270
|
+
state: phaseState,
|
|
1271
|
+
updatedAt: input.now.toISOString(),
|
|
1272
|
+
evidence: input.evidenceReference
|
|
1273
|
+
? [...base.phases[input.phase.id].evidence, input.evidenceReference]
|
|
1274
|
+
: base.phases[input.phase.id].evidence,
|
|
1275
|
+
approvals: appendUnique(base.phases[input.phase.id].approvals, input.plan.approval.envelopeId),
|
|
1276
|
+
blockers: input.blocker ? [input.blocker] : []
|
|
1277
|
+
};
|
|
1278
|
+
base.updatedAt = input.now.toISOString();
|
|
1279
|
+
return validateUserActivationState(base);
|
|
1280
|
+
}
|
|
1281
|
+
function blockedState(input) {
|
|
1282
|
+
const base = cloneState(input.inspection.state);
|
|
1283
|
+
base.phases[input.phase.id] = {
|
|
1284
|
+
state: 'blocked',
|
|
1285
|
+
updatedAt: input.now.toISOString(),
|
|
1286
|
+
evidence: base.phases[input.phase.id].evidence,
|
|
1287
|
+
approvals: appendUnique(base.phases[input.phase.id].approvals, input.plan.approval.envelopeId),
|
|
1288
|
+
blockers: [input.blocker]
|
|
1289
|
+
};
|
|
1290
|
+
base.updatedAt = input.now.toISOString();
|
|
1291
|
+
return validateUserActivationState(base);
|
|
1292
|
+
}
|
|
1293
|
+
async function assertLoadedStateHash(projectRoot, expectedHash) {
|
|
1294
|
+
const bytes = await readProjectFile(projectRoot, activationStatePathParts());
|
|
1295
|
+
const currentHash = bytes === undefined ? null : activationStateContentHash(bytes);
|
|
1296
|
+
if (currentHash !== expectedHash) {
|
|
1297
|
+
throw new Error(`Activation state changed after inspection: expected ${expectedHash ?? 'absent'}, found ${currentHash ?? 'absent'}.`);
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
async function writeOutcomeTransaction(input) {
|
|
1301
|
+
await assertLoadedStateHash(input.projectRoot, input.plan.stateHash);
|
|
1302
|
+
const stateContent = `${canonicalJson(validateUserActivationState(input.nextState))}\n`;
|
|
1303
|
+
const mutations = [
|
|
1304
|
+
...(input.fileMutations ?? []).map((mutation) => ({
|
|
1305
|
+
...mutation,
|
|
1306
|
+
pathParts: validateArtifactPathParts([...mutation.pathParts], 'Governance transition file mutation path')
|
|
1307
|
+
})),
|
|
1308
|
+
...(input.evidenceRecord && input.evidencePathParts
|
|
1309
|
+
? [{
|
|
1310
|
+
type: 'write',
|
|
1311
|
+
pathParts: [...input.evidencePathParts],
|
|
1312
|
+
content: `${canonicalJson(input.evidenceRecord)}\n`
|
|
1313
|
+
}]
|
|
1314
|
+
: []),
|
|
1315
|
+
{
|
|
1316
|
+
type: 'write',
|
|
1317
|
+
pathParts: activationStatePathParts(),
|
|
1318
|
+
content: stateContent
|
|
1319
|
+
}
|
|
1320
|
+
];
|
|
1321
|
+
if (input.evidencePathParts) {
|
|
1322
|
+
const existing = await readProjectFile(input.projectRoot, [...input.evidencePathParts]);
|
|
1323
|
+
if (existing !== undefined) {
|
|
1324
|
+
throw new Error(`Refusing to overwrite existing governance evidence ${input.evidencePathParts.join('/')}.`);
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
await applyProjectFileTransaction(input.projectRoot, mutations);
|
|
1328
|
+
return {
|
|
1329
|
+
stateHash: activationStateContentHash(stateContent),
|
|
1330
|
+
evidence: input.evidenceRecord && input.evidencePathParts
|
|
1331
|
+
? {
|
|
1332
|
+
evidenceId: input.evidenceRecord.evidenceId,
|
|
1333
|
+
pathParts: input.evidencePathParts,
|
|
1334
|
+
headerDigest: evidenceHeaderDigest(input.evidenceRecord.header),
|
|
1335
|
+
result: input.evidenceRecord.header.result
|
|
1336
|
+
}
|
|
1337
|
+
: null
|
|
1338
|
+
};
|
|
1339
|
+
}
|
|
1340
|
+
async function executeGitOperations(input, operations) {
|
|
1341
|
+
if (input.phase.id !== 'committed' && input.phase.id !== 'pushed') {
|
|
1342
|
+
return null;
|
|
1343
|
+
}
|
|
1344
|
+
const completed = [];
|
|
1345
|
+
for (const op of operations.filter((entry) => entry.adapter === 'git')) {
|
|
1346
|
+
if (op.actionId === 'git.verify-existing-commit' || op.actionId === 'git.verify-existing-push') {
|
|
1347
|
+
completed.push(op);
|
|
1348
|
+
continue;
|
|
1349
|
+
}
|
|
1350
|
+
if (op.actionId === 'git.init') {
|
|
1351
|
+
const result = await runGit(input.runner, input.inspection.projectRoot, ['init', '-b', String(op.inputs.branch)]);
|
|
1352
|
+
if (!commandSucceeded(result)) {
|
|
1353
|
+
return { status: 'blocked', blocker: commandFailure(result), completedOperations: completed };
|
|
1354
|
+
}
|
|
1355
|
+
completed.push(op);
|
|
1356
|
+
}
|
|
1357
|
+
else if (op.actionId === 'git.add-reviewed') {
|
|
1358
|
+
const paths = Array.isArray(op.inputs.paths) ? op.inputs.paths.map(String) : [];
|
|
1359
|
+
if (paths.length === 0) {
|
|
1360
|
+
completed.push(op);
|
|
1361
|
+
continue;
|
|
1362
|
+
}
|
|
1363
|
+
const result = await runGit(input.runner, input.inspection.projectRoot, ['add', '--', ...paths]);
|
|
1364
|
+
if (!commandSucceeded(result)) {
|
|
1365
|
+
return { status: 'blocked', blocker: commandFailure(result), completedOperations: completed };
|
|
1366
|
+
}
|
|
1367
|
+
completed.push(op);
|
|
1368
|
+
}
|
|
1369
|
+
else if (op.actionId === 'git.commit-reviewed') {
|
|
1370
|
+
const message = String(op.inputs.message);
|
|
1371
|
+
const result = await runGit(input.runner, input.inspection.projectRoot, ['commit', '-m', message]);
|
|
1372
|
+
if (!commandSucceeded(result)) {
|
|
1373
|
+
return { status: 'blocked', blocker: commandFailure(result), completedOperations: completed };
|
|
1374
|
+
}
|
|
1375
|
+
completed.push(op);
|
|
1376
|
+
}
|
|
1377
|
+
else if (op.actionId === 'git.push-approved-ref') {
|
|
1378
|
+
const branch = String(op.inputs.branch);
|
|
1379
|
+
const result = await runGit(input.runner, input.inspection.projectRoot, ['push', 'origin', `HEAD:refs/heads/${branch}`]);
|
|
1380
|
+
if (!commandSucceeded(result)) {
|
|
1381
|
+
return { status: 'blocked', blocker: commandFailure(result), completedOperations: completed };
|
|
1382
|
+
}
|
|
1383
|
+
completed.push(op);
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
return {
|
|
1387
|
+
status: 'completed',
|
|
1388
|
+
resultState: 'verified',
|
|
1389
|
+
completedOperations: completed
|
|
1390
|
+
};
|
|
1391
|
+
}
|
|
1392
|
+
async function executeSeedOperations(input) {
|
|
1393
|
+
if (!input.phase.id.startsWith('seed-')) {
|
|
1394
|
+
return null;
|
|
1395
|
+
}
|
|
1396
|
+
if (input.phase.id === 'seed-valid') {
|
|
1397
|
+
const result = await validateGeneratedSeedForPhase(input.inspection.projectRoot, input.runner);
|
|
1398
|
+
if (result.status === 'blocked') {
|
|
1399
|
+
return { status: 'blocked', blocker: result.issues[0] ?? 'Seed validation failed.', completedOperations: [] };
|
|
1400
|
+
}
|
|
1401
|
+
return { status: 'completed', resultState: 'verified', completedOperations: input.plan.operations.filter((op) => op.actionId === 'openspec.seed.validate') };
|
|
1402
|
+
}
|
|
1403
|
+
if (input.phase.id === 'seed-verified') {
|
|
1404
|
+
const result = await verifyGeneratedSeedBaselineForPhase(input.inspection.projectRoot, input.runner);
|
|
1405
|
+
if (result.status === 'blocked') {
|
|
1406
|
+
return { status: 'blocked', blocker: result.issues[0] ?? 'Seed baseline verification failed.', completedOperations: [] };
|
|
1407
|
+
}
|
|
1408
|
+
return {
|
|
1409
|
+
status: 'completed',
|
|
1410
|
+
resultState: 'verified',
|
|
1411
|
+
evidencePayload: {
|
|
1412
|
+
kind: 'seed-verified.v1',
|
|
1413
|
+
checks: result.checks.map((check) => ({
|
|
1414
|
+
id: check.id,
|
|
1415
|
+
taskId: check.taskId,
|
|
1416
|
+
status: check.status
|
|
1417
|
+
}))
|
|
1418
|
+
},
|
|
1419
|
+
completedOperations: input.plan.operations.filter((op) => op.actionId === 'openspec.seed.baseline-verify')
|
|
1420
|
+
};
|
|
1421
|
+
}
|
|
1422
|
+
const result = await archiveGeneratedSeedForPhase(input.inspection.projectRoot, input.runner);
|
|
1423
|
+
if (result.status === 'blocked') {
|
|
1424
|
+
return { status: 'blocked', blocker: result.issues[0] ?? 'Seed archive failed.', completedOperations: [] };
|
|
1425
|
+
}
|
|
1426
|
+
return {
|
|
1427
|
+
status: 'completed',
|
|
1428
|
+
resultState: 'verified',
|
|
1429
|
+
evidencePayload: {
|
|
1430
|
+
kind: 'seed-archived.v1',
|
|
1431
|
+
archiveSyncBehavior: result.archiveSyncBehavior ?? null
|
|
1432
|
+
},
|
|
1433
|
+
completedOperations: input.plan.operations.filter((op) => op.actionId === 'openspec.seed.archive')
|
|
1434
|
+
};
|
|
1435
|
+
}
|
|
1436
|
+
async function discoverPhase0(input) {
|
|
1437
|
+
if (input.phase.id !== 'phase-0-complete') {
|
|
1438
|
+
return null;
|
|
1439
|
+
}
|
|
1440
|
+
const gh = await runCommand(input.runner, {
|
|
1441
|
+
executable: 'gh',
|
|
1442
|
+
args: ['repo', 'view', '--json', 'id,nameWithOwner,defaultBranchRef,isPrivate']
|
|
1443
|
+
}, input.inspection.projectRoot);
|
|
1444
|
+
if (!commandSucceeded(gh)) {
|
|
1445
|
+
return {
|
|
1446
|
+
status: 'blocked',
|
|
1447
|
+
blocker: `Phase 0 GitHub read-only discovery failed: ${commandFailure(gh)}`,
|
|
1448
|
+
completedOperations: []
|
|
1449
|
+
};
|
|
1450
|
+
}
|
|
1451
|
+
let repo;
|
|
1452
|
+
try {
|
|
1453
|
+
repo = JSON.parse(gh.stdout);
|
|
1454
|
+
}
|
|
1455
|
+
catch (error) {
|
|
1456
|
+
return {
|
|
1457
|
+
status: 'blocked',
|
|
1458
|
+
blocker: `Phase 0 GitHub discovery returned invalid JSON: ${errorMessage(error)}`,
|
|
1459
|
+
completedOperations: []
|
|
1460
|
+
};
|
|
1461
|
+
}
|
|
1462
|
+
if (!repo.id || !repo.nameWithOwner || !repo.defaultBranchRef?.name) {
|
|
1463
|
+
return {
|
|
1464
|
+
status: 'blocked',
|
|
1465
|
+
blocker: 'Phase 0 GitHub discovery did not return repository id, nameWithOwner, and default branch.',
|
|
1466
|
+
completedOperations: []
|
|
1467
|
+
};
|
|
1468
|
+
}
|
|
1469
|
+
const az = await runCommand(input.runner, {
|
|
1470
|
+
executable: 'az',
|
|
1471
|
+
args: ['account', 'show', '--output', 'json']
|
|
1472
|
+
}, input.inspection.projectRoot);
|
|
1473
|
+
const azureAccount = commandSucceeded(az) ? safeJson(az.stdout) : null;
|
|
1474
|
+
const git = await inspectGitRepository(input.inspection.projectRoot, input.runner);
|
|
1475
|
+
const baselineDigest = canonicalSha256({
|
|
1476
|
+
gitHead: git.head,
|
|
1477
|
+
repositoryId: repo.id,
|
|
1478
|
+
repositoryName: repo.nameWithOwner,
|
|
1479
|
+
defaultBranch: repo.defaultBranchRef.name
|
|
1480
|
+
});
|
|
1481
|
+
const facts = {
|
|
1482
|
+
repositoryId: repo.id,
|
|
1483
|
+
repositoryName: repo.nameWithOwner,
|
|
1484
|
+
defaultBranch: repo.defaultBranchRef.name,
|
|
1485
|
+
baselineDigest,
|
|
1486
|
+
privateStagingDast: false,
|
|
1487
|
+
credentialRequired: false,
|
|
1488
|
+
statePath: 'none',
|
|
1489
|
+
approvedFacts: [
|
|
1490
|
+
{ id: 'repository.id', value: repo.id },
|
|
1491
|
+
{ id: 'repository.nameWithOwner', value: repo.nameWithOwner },
|
|
1492
|
+
{ id: 'repository.defaultBranch', value: repo.defaultBranchRef.name },
|
|
1493
|
+
{ id: 'repository.isPrivate', value: repo.isPrivate ?? null },
|
|
1494
|
+
{ id: 'azure.accountReadable', value: azureAccount !== null }
|
|
1495
|
+
]
|
|
1496
|
+
};
|
|
1497
|
+
const nextState = cloneState(input.inspection.state);
|
|
1498
|
+
nextState.repository = {
|
|
1499
|
+
id: facts.repositoryId,
|
|
1500
|
+
name: facts.repositoryName,
|
|
1501
|
+
defaultBranch: facts.defaultBranch
|
|
1502
|
+
};
|
|
1503
|
+
nextState.applicability = {
|
|
1504
|
+
statePath: facts.statePath,
|
|
1505
|
+
privateStagingDast: facts.privateStagingDast,
|
|
1506
|
+
credentialRequired: facts.credentialRequired
|
|
1507
|
+
};
|
|
1508
|
+
return {
|
|
1509
|
+
status: 'completed',
|
|
1510
|
+
resultState: 'verified',
|
|
1511
|
+
evidencePayload: {
|
|
1512
|
+
kind: 'phase-0-discovery.v1',
|
|
1513
|
+
facts: facts.approvedFacts,
|
|
1514
|
+
activationCreateChangePlan: {
|
|
1515
|
+
deterministic: true,
|
|
1516
|
+
graphHash: input.inspection.graphHash
|
|
1517
|
+
}
|
|
1518
|
+
},
|
|
1519
|
+
stateOverride: nextState,
|
|
1520
|
+
completedOperations: input.plan.operations.filter((op) => op.actionId === 'github.phase0.discover' || op.actionId === 'azure.phase0.discover')
|
|
1521
|
+
};
|
|
1522
|
+
}
|
|
1523
|
+
function safeJson(value) {
|
|
1524
|
+
try {
|
|
1525
|
+
return JSON.parse(value);
|
|
1526
|
+
}
|
|
1527
|
+
catch {
|
|
1528
|
+
return null;
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
async function executeActivationApproval(input) {
|
|
1532
|
+
if (input.phase.id !== 'activation-approved') {
|
|
1533
|
+
return null;
|
|
1534
|
+
}
|
|
1535
|
+
const state = cloneState(input.inspection.state);
|
|
1536
|
+
const fileMutations = [];
|
|
1537
|
+
if (input.inspection.sourceOfTruth.status === 'none') {
|
|
1538
|
+
const facts = buildApprovedPhase0FactsFromState(input.inspection.manifest, input.inspection.state, input.inspection.evidence);
|
|
1539
|
+
if (!facts || input.inspection.sourceOfTruth.createPlan.status !== 'ready') {
|
|
1540
|
+
return {
|
|
1541
|
+
status: 'blocked',
|
|
1542
|
+
blocker: input.inspection.sourceOfTruth.createPlan.reason,
|
|
1543
|
+
completedOperations: []
|
|
1544
|
+
};
|
|
1545
|
+
}
|
|
1546
|
+
const writePlan = renderGovernanceChangeWritePlan(facts);
|
|
1547
|
+
for (const file of writePlan.files) {
|
|
1548
|
+
const existing = await readProjectFile(input.inspection.projectRoot, [...file.pathParts]);
|
|
1549
|
+
if (existing !== undefined) {
|
|
1550
|
+
return {
|
|
1551
|
+
status: 'blocked',
|
|
1552
|
+
blocker: `Refusing to overwrite existing governance artifact ${file.pathParts.join('/')}.`,
|
|
1553
|
+
completedOperations: []
|
|
1554
|
+
};
|
|
1555
|
+
}
|
|
1556
|
+
fileMutations.push({
|
|
1557
|
+
type: 'write',
|
|
1558
|
+
pathParts: [...file.pathParts],
|
|
1559
|
+
content: file.content
|
|
1560
|
+
});
|
|
1561
|
+
}
|
|
1562
|
+
state.activeChange = {
|
|
1563
|
+
id: writePlan.changeId,
|
|
1564
|
+
kind: writePlan.workflowKind === 'openspec' ? 'openspec' : 'spec-kit'
|
|
1565
|
+
};
|
|
1566
|
+
}
|
|
1567
|
+
else if (input.inspection.sourceOfTruth.status === 'selected') {
|
|
1568
|
+
state.activeChange = {
|
|
1569
|
+
id: input.inspection.sourceOfTruth.selected.changeId,
|
|
1570
|
+
kind: input.inspection.sourceOfTruth.selected.workflowKind === 'openspec' ? 'openspec' : 'spec-kit'
|
|
1571
|
+
};
|
|
1572
|
+
}
|
|
1573
|
+
else {
|
|
1574
|
+
return {
|
|
1575
|
+
status: 'blocked',
|
|
1576
|
+
blocker: 'A compatible governance source of truth is required before activation approval can be recorded.',
|
|
1577
|
+
completedOperations: []
|
|
1578
|
+
};
|
|
1579
|
+
}
|
|
1580
|
+
return {
|
|
1581
|
+
status: 'completed',
|
|
1582
|
+
resultState: 'approved',
|
|
1583
|
+
stateOverride: state,
|
|
1584
|
+
fileMutations,
|
|
1585
|
+
completedOperations: input.plan.operations.filter((op) => op.actionId.endsWith('governance.create-change'))
|
|
1586
|
+
};
|
|
1587
|
+
}
|
|
1588
|
+
async function executeCredentialReady(input) {
|
|
1589
|
+
if (input.phase.id !== 'credential-ready') {
|
|
1590
|
+
return null;
|
|
1591
|
+
}
|
|
1592
|
+
const policy = await readProjectFile(input.inspection.projectRoot, [...credentialPolicyPathParts]);
|
|
1593
|
+
if (policy === undefined) {
|
|
1594
|
+
return {
|
|
1595
|
+
status: 'blocked',
|
|
1596
|
+
blocker: 'Credential enrollment requires a secure masked input channel or an existing selected-repository App; automated noninteractive apply cannot collect a PAT.',
|
|
1597
|
+
completedOperations: []
|
|
1598
|
+
};
|
|
1599
|
+
}
|
|
1600
|
+
const scan = detectCredentialLeaks([{
|
|
1601
|
+
source: 'imported-evidence',
|
|
1602
|
+
label: credentialPolicyPathParts.join('/'),
|
|
1603
|
+
text: policy.toString('utf8')
|
|
1604
|
+
}]);
|
|
1605
|
+
if (scan.status === 'compromised') {
|
|
1606
|
+
return {
|
|
1607
|
+
status: 'blocked',
|
|
1608
|
+
blocker: scan.guidance.join(' '),
|
|
1609
|
+
completedOperations: []
|
|
1610
|
+
};
|
|
1611
|
+
}
|
|
1612
|
+
return {
|
|
1613
|
+
status: 'completed',
|
|
1614
|
+
resultState: 'verified',
|
|
1615
|
+
evidencePayload: {
|
|
1616
|
+
kind: 'credential-ready.v1',
|
|
1617
|
+
policyPathParts: credentialPolicyPathParts,
|
|
1618
|
+
payloadFree: true,
|
|
1619
|
+
policyDigest: canonicalSha256(policy.toString('utf8'))
|
|
1620
|
+
},
|
|
1621
|
+
completedOperations: input.plan.operations.filter((op) => op.actionId === 'github.credential.verify-policy')
|
|
1622
|
+
};
|
|
1623
|
+
}
|
|
1624
|
+
function remoteImportRetention(input) {
|
|
1625
|
+
if (input.phase.id !== 'remote-ready') {
|
|
1626
|
+
return null;
|
|
1627
|
+
}
|
|
1628
|
+
if (input.inspection.state.applicability.statePath !== 'bootstrap-local') {
|
|
1629
|
+
return {
|
|
1630
|
+
status: 'completed',
|
|
1631
|
+
resultState: 'verified',
|
|
1632
|
+
evidencePayload: { kind: 'remote-ready.v1', retention: 'not-applicable' },
|
|
1633
|
+
completedOperations: input.plan.operations.filter((op) => op.actionId === 'azure.remote-ready.verify')
|
|
1634
|
+
};
|
|
1635
|
+
}
|
|
1636
|
+
const importRecord = latestRecordWithPayload(input.inspection, 'remote-import-verified');
|
|
1637
|
+
if (!importRecord || !isRecord(importRecord.payload) || importRecord.payload.kind !== remoteImportPayloadKind) {
|
|
1638
|
+
return {
|
|
1639
|
+
status: 'blocked',
|
|
1640
|
+
blocker: 'Remote import retention requires immutable remote-import evidence with state and key identifiers.',
|
|
1641
|
+
completedOperations: []
|
|
1642
|
+
};
|
|
1643
|
+
}
|
|
1644
|
+
const encryptedStatePathParts = pathPartLists(importRecord.payload.encryptedStatePathParts, 'encryptedStatePathParts');
|
|
1645
|
+
const encryptionKeyPathParts = pathPartLists(importRecord.payload.encryptionKeyPathParts, 'encryptionKeyPathParts');
|
|
1646
|
+
const retainedAt = input.now.toISOString();
|
|
1647
|
+
const disposeAfter = new Date(input.now.getTime() + 30 * dayMs).toISOString();
|
|
1648
|
+
const state = cloneState(input.inspection.state);
|
|
1649
|
+
state.bootstrapState = {
|
|
1650
|
+
status: 'retained',
|
|
1651
|
+
remoteImportEvidenceId: importRecord.evidenceId,
|
|
1652
|
+
remoteImportEvidenceDigest: evidenceHeaderDigest(importRecord.header),
|
|
1653
|
+
retainedAt,
|
|
1654
|
+
disposeAfter,
|
|
1655
|
+
encryptedStatePathParts,
|
|
1656
|
+
encryptionKeyPathParts
|
|
1657
|
+
};
|
|
1658
|
+
return {
|
|
1659
|
+
status: 'completed',
|
|
1660
|
+
resultState: 'retained',
|
|
1661
|
+
stateOverride: state,
|
|
1662
|
+
evidencePayload: {
|
|
1663
|
+
kind: 'remote-ready.v1',
|
|
1664
|
+
retention: state.bootstrapState
|
|
1665
|
+
},
|
|
1666
|
+
completedOperations: input.plan.operations.filter((op) => op.actionId === 'azure.remote-ready.verify')
|
|
1667
|
+
};
|
|
1668
|
+
}
|
|
1669
|
+
function pathPartLists(value, label) {
|
|
1670
|
+
if (!Array.isArray(value)) {
|
|
1671
|
+
throw new Error(`${label} must be an array of path-part arrays.`);
|
|
1672
|
+
}
|
|
1673
|
+
return value.map((entry, index) => validateArtifactPathParts(entry, `${label}[${index}]`));
|
|
1674
|
+
}
|
|
1675
|
+
async function executeBootstrapStateDisposal(input) {
|
|
1676
|
+
if (input.phase.id !== 'bootstrap-state-disposed') {
|
|
1677
|
+
return null;
|
|
1678
|
+
}
|
|
1679
|
+
const retention = input.inspection.state.bootstrapState;
|
|
1680
|
+
if (!retention || input.inspection.state.applicability.statePath !== 'bootstrap-local') {
|
|
1681
|
+
return {
|
|
1682
|
+
status: 'completed',
|
|
1683
|
+
resultState: 'inapplicable',
|
|
1684
|
+
evidencePayload: { kind: bootstrapStateDisposedPayloadKind, reason: 'no retained bootstrap state' },
|
|
1685
|
+
completedOperations: []
|
|
1686
|
+
};
|
|
1687
|
+
}
|
|
1688
|
+
if (retention.status === 'disposed') {
|
|
1689
|
+
return {
|
|
1690
|
+
status: 'completed',
|
|
1691
|
+
resultState: 'disposed',
|
|
1692
|
+
evidencePayload: { kind: bootstrapStateDisposedPayloadKind, reason: 'already disposed', deletionEvidenceId: retention.deletionEvidenceId ?? null },
|
|
1693
|
+
completedOperations: []
|
|
1694
|
+
};
|
|
1695
|
+
}
|
|
1696
|
+
if (Date.parse(retention.disposeAfter) > input.now.getTime()) {
|
|
1697
|
+
return {
|
|
1698
|
+
status: 'blocked',
|
|
1699
|
+
blocker: `Retained bootstrap state is not disposable until ${retention.disposeAfter}.`,
|
|
1700
|
+
completedOperations: []
|
|
1701
|
+
};
|
|
1702
|
+
}
|
|
1703
|
+
const importRecord = input.inspection.evidence.find((record) => record.evidenceId === retention.remoteImportEvidenceId);
|
|
1704
|
+
if (!importRecord || evidenceHeaderDigest(importRecord.header) !== retention.remoteImportEvidenceDigest) {
|
|
1705
|
+
return {
|
|
1706
|
+
status: 'blocked',
|
|
1707
|
+
blocker: 'Destructive disposal requires the immutable remote-import evidence referenced by retained bootstrap state.',
|
|
1708
|
+
completedOperations: []
|
|
1709
|
+
};
|
|
1710
|
+
}
|
|
1711
|
+
if (!isRecord(importRecord.payload) || importRecord.payload.kind !== remoteImportPayloadKind) {
|
|
1712
|
+
return {
|
|
1713
|
+
status: 'blocked',
|
|
1714
|
+
blocker: 'Destructive disposal requires verified remote backend and no-change evidence.',
|
|
1715
|
+
completedOperations: []
|
|
1716
|
+
};
|
|
1717
|
+
}
|
|
1718
|
+
const remoteBackendDigest = importRecord.payload.remoteBackendDigest;
|
|
1719
|
+
const noChangePlanDigest = importRecord.payload.noChangePlanDigest;
|
|
1720
|
+
if (typeof remoteBackendDigest !== 'string' ||
|
|
1721
|
+
!/^[a-f0-9]{64}$/u.test(remoteBackendDigest) ||
|
|
1722
|
+
typeof noChangePlanDigest !== 'string' ||
|
|
1723
|
+
!/^[a-f0-9]{64}$/u.test(noChangePlanDigest)) {
|
|
1724
|
+
return {
|
|
1725
|
+
status: 'blocked',
|
|
1726
|
+
blocker: 'Destructive disposal requires payload-free remote backend and no-change plan digests.',
|
|
1727
|
+
completedOperations: []
|
|
1728
|
+
};
|
|
1729
|
+
}
|
|
1730
|
+
const allPaths = [...retention.encryptedStatePathParts, ...retention.encryptionKeyPathParts]
|
|
1731
|
+
.map((parts) => validateArtifactPathParts([...parts], 'Bootstrap disposal path'));
|
|
1732
|
+
const incompleteCleanup = [];
|
|
1733
|
+
for (const parts of allPaths) {
|
|
1734
|
+
try {
|
|
1735
|
+
const target = await resolveProjectPath(input.inspection.projectRoot, parts);
|
|
1736
|
+
const details = await stat(target);
|
|
1737
|
+
if (!details.isFile()) {
|
|
1738
|
+
incompleteCleanup.push(`${parts.join('/')} is not a regular file.`);
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
catch (error) {
|
|
1742
|
+
incompleteCleanup.push(`${parts.join('/')} was already absent before disposal: ${errorMessage(error)}`);
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
const state = cloneState(input.inspection.state);
|
|
1746
|
+
state.bootstrapState = {
|
|
1747
|
+
...retention,
|
|
1748
|
+
status: 'disposed',
|
|
1749
|
+
disposedAt: input.now.toISOString(),
|
|
1750
|
+
deletionEvidenceId: `${input.phase.id}-${safeTimestamp(input.now.toISOString())}`,
|
|
1751
|
+
incompleteCleanup
|
|
1752
|
+
};
|
|
1753
|
+
return {
|
|
1754
|
+
status: 'completed',
|
|
1755
|
+
resultState: 'disposed',
|
|
1756
|
+
stateOverride: state,
|
|
1757
|
+
evidencePayload: {
|
|
1758
|
+
kind: bootstrapStateDisposedPayloadKind,
|
|
1759
|
+
deletedPathParts: allPaths,
|
|
1760
|
+
remoteBackendDigest,
|
|
1761
|
+
noChangePlanDigest,
|
|
1762
|
+
incompleteCleanup,
|
|
1763
|
+
payloadFree: true
|
|
1764
|
+
},
|
|
1765
|
+
fileMutations: allPaths.map((parts) => ({ type: 'delete', pathParts: parts })),
|
|
1766
|
+
completedOperations: input.plan.operations.filter((op) => op.actionId === 'local.bootstrap-state.dispose'),
|
|
1767
|
+
cleanupWarnings: incompleteCleanup
|
|
1768
|
+
};
|
|
1769
|
+
}
|
|
1770
|
+
function assertGreenRedProof(input) {
|
|
1771
|
+
const record = latestRecordWithPayload(input.inspection, 'green-red-proof');
|
|
1772
|
+
if (!record || !isRecord(record.payload) || record.payload.kind !== greenRedPayloadKind) {
|
|
1773
|
+
throw new Error('Ruleset enforcement requires current green-red-proof evidence with typed proof payload.');
|
|
1774
|
+
}
|
|
1775
|
+
const green = isRecord(record.payload.green) ? record.payload.green : null;
|
|
1776
|
+
const red = isRecord(record.payload.deliberateRed) ? record.payload.deliberateRed : null;
|
|
1777
|
+
const greenConclusion = green?.conclusion;
|
|
1778
|
+
const redConclusion = red?.conclusion;
|
|
1779
|
+
const badConclusions = [greenConclusion, redConclusion].filter((value) => value === 'skipped' || value === 'cancelled' || value === 'neutral');
|
|
1780
|
+
if (badConclusions.length > 0) {
|
|
1781
|
+
throw new Error('Skipped, cancelled, and neutral checks cannot satisfy green/red proof.');
|
|
1782
|
+
}
|
|
1783
|
+
if (!red || greenConclusion !== 'success' || redConclusion !== 'failure' || red.deliberate !== true) {
|
|
1784
|
+
throw new Error('Ruleset enforcement requires green success and deliberate red failure proof.');
|
|
1785
|
+
}
|
|
1786
|
+
return record;
|
|
1787
|
+
}
|
|
1788
|
+
async function executeRulesetPhase(input) {
|
|
1789
|
+
if (input.phase.id !== 'rulesets-applied' && input.phase.id !== 'live-readback') {
|
|
1790
|
+
return null;
|
|
1791
|
+
}
|
|
1792
|
+
try {
|
|
1793
|
+
assertGreenRedProof(input);
|
|
1794
|
+
}
|
|
1795
|
+
catch (error) {
|
|
1796
|
+
return { status: 'blocked', blocker: errorMessage(error), completedOperations: [] };
|
|
1797
|
+
}
|
|
1798
|
+
const sourceDigest = rulesetSourceDigestFromEvidence(input.inspection);
|
|
1799
|
+
if (!sourceDigest) {
|
|
1800
|
+
return {
|
|
1801
|
+
status: 'blocked',
|
|
1802
|
+
blocker: 'Ruleset enforcement requires a saved ruleset source digest from workflow-source-ready evidence.',
|
|
1803
|
+
completedOperations: []
|
|
1804
|
+
};
|
|
1805
|
+
}
|
|
1806
|
+
const adapter = input.adapters.githubRulesets;
|
|
1807
|
+
if (!adapter) {
|
|
1808
|
+
return {
|
|
1809
|
+
status: 'blocked',
|
|
1810
|
+
blocker: 'No injected GitHub ruleset adapter is configured; refusing live ruleset calls in this execution context.',
|
|
1811
|
+
completedOperations: []
|
|
1812
|
+
};
|
|
1813
|
+
}
|
|
1814
|
+
const envelopeId = input.plan.approval.envelopeId;
|
|
1815
|
+
if (input.phase.id === 'rulesets-applied' && !envelopeId) {
|
|
1816
|
+
return {
|
|
1817
|
+
status: 'blocked',
|
|
1818
|
+
blocker: 'Ruleset enforcement requires a persisted enforcement approval envelope.',
|
|
1819
|
+
completedOperations: []
|
|
1820
|
+
};
|
|
1821
|
+
}
|
|
1822
|
+
const completed = [];
|
|
1823
|
+
const write = input.phase.id === 'rulesets-applied'
|
|
1824
|
+
? await adapter.applyRuleset({
|
|
1825
|
+
repository: input.inspection.state.repository.name,
|
|
1826
|
+
sourceDigest,
|
|
1827
|
+
approvalEnvelopeId: envelopeId ?? 'ungated-readback'
|
|
1828
|
+
})
|
|
1829
|
+
: await adapter.readRuleset({
|
|
1830
|
+
repository: input.inspection.state.repository.name,
|
|
1831
|
+
sourceDigest
|
|
1832
|
+
});
|
|
1833
|
+
completed.push(...input.plan.operations.filter((op) => input.phase.id === 'rulesets-applied'
|
|
1834
|
+
? op.actionId === 'github.ruleset.apply' || op.actionId === 'github.ruleset.readback'
|
|
1835
|
+
: op.actionId === 'github.ruleset.readback'));
|
|
1836
|
+
if (write.sourceDigest !== sourceDigest || write.readbackDigest !== sourceDigest) {
|
|
1837
|
+
return {
|
|
1838
|
+
status: 'blocked',
|
|
1839
|
+
blocker: 'Post-write live ruleset readback did not match the saved source digest.',
|
|
1840
|
+
completedOperations: completed
|
|
1841
|
+
};
|
|
1842
|
+
}
|
|
1843
|
+
const header = evidenceHeaderFor({
|
|
1844
|
+
inspection: input.inspection,
|
|
1845
|
+
phase: input.phase,
|
|
1846
|
+
plan: input.plan,
|
|
1847
|
+
result: 'verified',
|
|
1848
|
+
now: input.now
|
|
1849
|
+
});
|
|
1850
|
+
const liveReadback = {
|
|
1851
|
+
schemaVersion: input.inspection.state.identity.evidenceHeaderSchemaVersion,
|
|
1852
|
+
repositoryId: header.repositoryId,
|
|
1853
|
+
identity: header.identity,
|
|
1854
|
+
phaseGraphHash: header.phaseGraphHash,
|
|
1855
|
+
phaseId: header.phaseId,
|
|
1856
|
+
baselineSha: header.baselineSha,
|
|
1857
|
+
inputDigest: header.inputDigest,
|
|
1858
|
+
transition: header.transition,
|
|
1859
|
+
observedAt: input.now.toISOString(),
|
|
1860
|
+
provider: 'github',
|
|
1861
|
+
resourceType: 'ruleset',
|
|
1862
|
+
resourceId: write.resourceId,
|
|
1863
|
+
sourceDigest,
|
|
1864
|
+
readbackDigest: write.readbackDigest,
|
|
1865
|
+
matches: true
|
|
1866
|
+
};
|
|
1867
|
+
return {
|
|
1868
|
+
status: 'completed',
|
|
1869
|
+
resultState: 'verified',
|
|
1870
|
+
liveReadback: [liveReadback],
|
|
1871
|
+
evidencePayload: {
|
|
1872
|
+
kind: `${input.phase.id}.v1`,
|
|
1873
|
+
sourceDigest,
|
|
1874
|
+
resourceId: write.resourceId,
|
|
1875
|
+
readbackDigest: write.readbackDigest
|
|
1876
|
+
},
|
|
1877
|
+
completedOperations: completed
|
|
1878
|
+
};
|
|
1879
|
+
}
|
|
1880
|
+
async function executeBuiltInPhase(input) {
|
|
1881
|
+
const custom = input.adapters.phases?.[input.phase.id];
|
|
1882
|
+
if (custom) {
|
|
1883
|
+
return await custom.execute(input);
|
|
1884
|
+
}
|
|
1885
|
+
for (const attempt of [
|
|
1886
|
+
executeSeedOperations,
|
|
1887
|
+
async (execution) => executeGitOperations(execution, execution.plan.operations),
|
|
1888
|
+
discoverPhase0,
|
|
1889
|
+
executeActivationApproval,
|
|
1890
|
+
executeCredentialReady,
|
|
1891
|
+
async (execution) => remoteImportRetention(execution),
|
|
1892
|
+
executeBootstrapStateDisposal,
|
|
1893
|
+
executeRulesetPhase
|
|
1894
|
+
]) {
|
|
1895
|
+
const outcome = await attempt(input);
|
|
1896
|
+
if (outcome) {
|
|
1897
|
+
return outcome;
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
return {
|
|
1901
|
+
status: 'blocked',
|
|
1902
|
+
blocker: `No production adapter is configured for ${input.phase.id}; refusing success-shaped fallback.`,
|
|
1903
|
+
completedOperations: []
|
|
1904
|
+
};
|
|
1905
|
+
}
|
|
1906
|
+
function sourceOfTruthAllowsPhase(inspection, phaseId) {
|
|
1907
|
+
if (phaseOrder(phaseId) <= phaseOrder('phase-0-complete')) {
|
|
1908
|
+
return null;
|
|
1909
|
+
}
|
|
1910
|
+
const source = inspection.sourceOfTruth;
|
|
1911
|
+
if (phaseId === 'activation-approved' && (source.status === 'none' || source.status === 'selected')) {
|
|
1912
|
+
return null;
|
|
1913
|
+
}
|
|
1914
|
+
if (source.status === 'selected' && source.reconciliation.status === 'not-required') {
|
|
1915
|
+
return null;
|
|
1916
|
+
}
|
|
1917
|
+
if (source.status === 'seed-blocked') {
|
|
1918
|
+
return source.blockers.join('; ');
|
|
1919
|
+
}
|
|
1920
|
+
if (source.status === 'ambiguous' || source.status === 'incompatible') {
|
|
1921
|
+
return source.blockers.join('; ');
|
|
1922
|
+
}
|
|
1923
|
+
if (source.status === 'none') {
|
|
1924
|
+
return source.createPlan.reason;
|
|
1925
|
+
}
|
|
1926
|
+
return 'Active governance source of truth is not ready.';
|
|
1927
|
+
}
|
|
1928
|
+
export async function executeApplyNext(input) {
|
|
1929
|
+
const runner = input.runner ?? new NodeCommandRunner();
|
|
1930
|
+
const adapters = input.adapters ?? {};
|
|
1931
|
+
const now = input.now ?? new Date();
|
|
1932
|
+
const initialPlan = await buildSavedTransitionPlan({ inspection: input.inspection, runner, now });
|
|
1933
|
+
if (!initialPlan) {
|
|
1934
|
+
const preview = await previewApplyNext({ inspection: input.inspection, runner, now, execute: true });
|
|
1935
|
+
return {
|
|
1936
|
+
...preview,
|
|
1937
|
+
applied: false,
|
|
1938
|
+
noWrites: false,
|
|
1939
|
+
executedOperations: [],
|
|
1940
|
+
evidence: null,
|
|
1941
|
+
stateHash: null,
|
|
1942
|
+
rollbackPlan: rollbackPlanFromCompletedOperations(input.inspection.readiness.nextReadyPhase ?? 'seed-valid', 'none', null, []),
|
|
1943
|
+
cleanupWarnings: []
|
|
1944
|
+
};
|
|
1945
|
+
}
|
|
1946
|
+
const phase = phaseById(input.inspection.graph, initialPlan.phaseId);
|
|
1947
|
+
assertPlanOperationsAllowed(initialPlan, phase);
|
|
1948
|
+
if (initialPlan.approval.evaluation.approvalRequired) {
|
|
1949
|
+
const preview = await previewApplyNext({ inspection: input.inspection, runner, now, execute: true });
|
|
1950
|
+
return {
|
|
1951
|
+
...preview,
|
|
1952
|
+
applied: false,
|
|
1953
|
+
noWrites: false,
|
|
1954
|
+
executedOperations: [],
|
|
1955
|
+
evidence: null,
|
|
1956
|
+
stateHash: null,
|
|
1957
|
+
rollbackPlan: initialPlan.rollbackPlan,
|
|
1958
|
+
cleanupWarnings: []
|
|
1959
|
+
};
|
|
1960
|
+
}
|
|
1961
|
+
const saved = await saveTransitionPlan(input.inspection.projectRoot, initialPlan);
|
|
1962
|
+
const freshInspection = await input.reinspect();
|
|
1963
|
+
const freshPlan = await buildSavedTransitionPlan({ inspection: freshInspection, runner, now });
|
|
1964
|
+
const freshnessIssues = comparePlanFreshness(initialPlan, freshPlan);
|
|
1965
|
+
if (freshnessIssues.length > 0) {
|
|
1966
|
+
return {
|
|
1967
|
+
schemaVersion: 1,
|
|
1968
|
+
command: 'governance apply-next',
|
|
1969
|
+
projectRoot: input.inspection.projectRoot,
|
|
1970
|
+
execute: true,
|
|
1971
|
+
applied: false,
|
|
1972
|
+
authorized: false,
|
|
1973
|
+
reason: 'stale-after-plan-save',
|
|
1974
|
+
message: freshnessIssues.join(' '),
|
|
1975
|
+
nextReadyPhase: freshInspection.readiness.nextReadyPhase,
|
|
1976
|
+
approval: initialPlan.approval,
|
|
1977
|
+
proposedMutations: {
|
|
1978
|
+
local: initialPlan.mutationClasses.local,
|
|
1979
|
+
remote: initialPlan.mutationClasses.remote,
|
|
1980
|
+
operations: initialPlan.operations
|
|
1981
|
+
},
|
|
1982
|
+
savedPlan: saved,
|
|
1983
|
+
noWrites: false,
|
|
1984
|
+
blockers: freshnessIssues,
|
|
1985
|
+
executedOperations: [],
|
|
1986
|
+
evidence: null,
|
|
1987
|
+
stateHash: null,
|
|
1988
|
+
rollbackPlan: initialPlan.rollbackPlan,
|
|
1989
|
+
cleanupWarnings: []
|
|
1990
|
+
};
|
|
1991
|
+
}
|
|
1992
|
+
const sourceBlocker = sourceOfTruthAllowsPhase(freshInspection, initialPlan.phaseId);
|
|
1993
|
+
if (sourceBlocker) {
|
|
1994
|
+
const nextState = blockedState({ inspection: freshInspection, phase, plan: initialPlan, blocker: sourceBlocker, now });
|
|
1995
|
+
const write = await writeOutcomeTransaction({
|
|
1996
|
+
projectRoot: freshInspection.projectRoot,
|
|
1997
|
+
plan: initialPlan,
|
|
1998
|
+
nextState
|
|
1999
|
+
});
|
|
2000
|
+
return executionBlockedResult(freshInspection, initialPlan, saved, sourceBlocker, [], write.stateHash);
|
|
2001
|
+
}
|
|
2002
|
+
const outcome = await executeBuiltInPhase({
|
|
2003
|
+
inspection: freshInspection,
|
|
2004
|
+
plan: initialPlan,
|
|
2005
|
+
phase,
|
|
2006
|
+
runner,
|
|
2007
|
+
adapters,
|
|
2008
|
+
now
|
|
2009
|
+
});
|
|
2010
|
+
const completedOperations = outcome.completedOperations ?? [];
|
|
2011
|
+
if (outcome.status === 'blocked') {
|
|
2012
|
+
const blocker = outcome.blocker ?? `Phase ${phase.id} blocked.`;
|
|
2013
|
+
const nextState = blockedState({ inspection: freshInspection, phase, plan: initialPlan, blocker, now });
|
|
2014
|
+
const write = await writeOutcomeTransaction({
|
|
2015
|
+
projectRoot: freshInspection.projectRoot,
|
|
2016
|
+
plan: initialPlan,
|
|
2017
|
+
nextState
|
|
2018
|
+
});
|
|
2019
|
+
return executionBlockedResult(freshInspection, initialPlan, saved, blocker, completedOperations, write.stateHash, outcome.cleanupWarnings ?? []);
|
|
2020
|
+
}
|
|
2021
|
+
const resultState = outcome.resultState ?? 'verified';
|
|
2022
|
+
let evidenceRecord;
|
|
2023
|
+
let evidenceParts;
|
|
2024
|
+
let evidenceReference;
|
|
2025
|
+
if (resultState !== 'approved') {
|
|
2026
|
+
const evidenceId = `${phase.id}-${safeTimestamp(now.toISOString())}`;
|
|
2027
|
+
const header = evidenceHeaderFor({
|
|
2028
|
+
inspection: freshInspection,
|
|
2029
|
+
phase,
|
|
2030
|
+
plan: initialPlan,
|
|
2031
|
+
result: resultState,
|
|
2032
|
+
now
|
|
2033
|
+
});
|
|
2034
|
+
evidenceRecord = {
|
|
2035
|
+
evidenceId,
|
|
2036
|
+
header,
|
|
2037
|
+
...(outcome.liveReadback ? { liveReadback: outcome.liveReadback } : {}),
|
|
2038
|
+
...(outcome.evidencePayload !== undefined ? { payload: outcome.evidencePayload } : {})
|
|
2039
|
+
};
|
|
2040
|
+
evidenceParts = evidencePathParts(evidenceId);
|
|
2041
|
+
evidenceReference = {
|
|
2042
|
+
phaseId: phase.id,
|
|
2043
|
+
evidenceId,
|
|
2044
|
+
headerDigest: evidenceHeaderDigest(header),
|
|
2045
|
+
result: header.result
|
|
2046
|
+
};
|
|
2047
|
+
}
|
|
2048
|
+
const nextState = nextStateForOutcome({
|
|
2049
|
+
inspection: freshInspection,
|
|
2050
|
+
phase,
|
|
2051
|
+
plan: initialPlan,
|
|
2052
|
+
resultState,
|
|
2053
|
+
evidenceReference,
|
|
2054
|
+
override: outcome.stateOverride,
|
|
2055
|
+
now
|
|
2056
|
+
});
|
|
2057
|
+
const write = await writeOutcomeTransaction({
|
|
2058
|
+
projectRoot: freshInspection.projectRoot,
|
|
2059
|
+
plan: initialPlan,
|
|
2060
|
+
nextState,
|
|
2061
|
+
evidenceRecord,
|
|
2062
|
+
evidencePathParts: evidenceParts,
|
|
2063
|
+
fileMutations: outcome.fileMutations
|
|
2064
|
+
});
|
|
2065
|
+
const rollbackPlan = rollbackPlanForPhase(phase, completedOperations);
|
|
2066
|
+
return {
|
|
2067
|
+
schemaVersion: 1,
|
|
2068
|
+
command: 'governance apply-next',
|
|
2069
|
+
projectRoot: freshInspection.projectRoot,
|
|
2070
|
+
execute: true,
|
|
2071
|
+
applied: true,
|
|
2072
|
+
authorized: true,
|
|
2073
|
+
reason: 'phase-executed',
|
|
2074
|
+
message: `Executed one phase: ${phase.id}.`,
|
|
2075
|
+
nextReadyPhase: phase.id,
|
|
2076
|
+
approval: initialPlan.approval,
|
|
2077
|
+
proposedMutations: {
|
|
2078
|
+
local: initialPlan.mutationClasses.local,
|
|
2079
|
+
remote: initialPlan.mutationClasses.remote,
|
|
2080
|
+
operations: initialPlan.operations
|
|
2081
|
+
},
|
|
2082
|
+
savedPlan: saved,
|
|
2083
|
+
noWrites: false,
|
|
2084
|
+
blockers: [],
|
|
2085
|
+
executedOperations: [
|
|
2086
|
+
...completedOperations,
|
|
2087
|
+
...(evidenceParts ? [evidenceWriteOperation(phase, evidenceParts)] : []),
|
|
2088
|
+
stateWriteOperation(phase)
|
|
2089
|
+
],
|
|
2090
|
+
evidence: write.evidence,
|
|
2091
|
+
stateHash: write.stateHash,
|
|
2092
|
+
rollbackPlan,
|
|
2093
|
+
cleanupWarnings: [...rollbackPlan.cleanupWarnings, ...(outcome.cleanupWarnings ?? [])]
|
|
2094
|
+
};
|
|
2095
|
+
}
|
|
2096
|
+
function executionBlockedResult(inspection, plan, saved, blocker, completedOperations, stateHashValue, cleanupWarnings = []) {
|
|
2097
|
+
const phase = phaseById(inspection.graph, plan.phaseId);
|
|
2098
|
+
const rollbackPlan = rollbackPlanForPhase(phase, completedOperations);
|
|
2099
|
+
return {
|
|
2100
|
+
schemaVersion: 1,
|
|
2101
|
+
command: 'governance apply-next',
|
|
2102
|
+
projectRoot: inspection.projectRoot,
|
|
2103
|
+
execute: true,
|
|
2104
|
+
applied: false,
|
|
2105
|
+
authorized: false,
|
|
2106
|
+
reason: 'blocked',
|
|
2107
|
+
message: blocker,
|
|
2108
|
+
nextReadyPhase: plan.phaseId,
|
|
2109
|
+
approval: plan.approval,
|
|
2110
|
+
proposedMutations: {
|
|
2111
|
+
local: plan.mutationClasses.local,
|
|
2112
|
+
remote: plan.mutationClasses.remote,
|
|
2113
|
+
operations: plan.operations
|
|
2114
|
+
},
|
|
2115
|
+
savedPlan: saved,
|
|
2116
|
+
noWrites: false,
|
|
2117
|
+
blockers: [blocker],
|
|
2118
|
+
executedOperations: [
|
|
2119
|
+
...completedOperations,
|
|
2120
|
+
stateWriteOperation(phase)
|
|
2121
|
+
],
|
|
2122
|
+
evidence: null,
|
|
2123
|
+
stateHash: stateHashValue,
|
|
2124
|
+
rollbackPlan,
|
|
2125
|
+
cleanupWarnings: [...rollbackPlan.cleanupWarnings, ...cleanupWarnings]
|
|
2126
|
+
};
|
|
2127
|
+
}
|
|
2128
|
+
//# sourceMappingURL=transitions.js.map
|