codex-workflow-v2 2.0.0-beta.13.9 → 2.0.0-beta.14
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/README.md +27 -388
- package/dist/reviewer-runtime-build.json +89 -29
- package/dist/src/alpha6/adoption.d.ts +2 -0
- package/dist/src/alpha6/adoption.js +22 -0
- package/dist/src/alpha6/adoption.js.map +1 -1
- package/dist/src/alpha6/captured-check-evidence.d.ts +51 -0
- package/dist/src/alpha6/captured-check-evidence.js +152 -0
- package/dist/src/alpha6/captured-check-evidence.js.map +1 -0
- package/dist/src/alpha6/component-owner.d.ts +15 -1
- package/dist/src/alpha6/component-owner.js +44 -2
- package/dist/src/alpha6/component-owner.js.map +1 -1
- package/dist/src/alpha6/corrective-decision-boundary.d.ts +4 -0
- package/dist/src/alpha6/corrective-decision-boundary.js +47 -0
- package/dist/src/alpha6/corrective-decision-boundary.js.map +1 -0
- package/dist/src/alpha6/downstream-proof.d.ts +1 -25
- package/dist/src/alpha6/downstream-proof.js +0 -171
- package/dist/src/alpha6/downstream-proof.js.map +1 -1
- package/dist/src/alpha6/literal-test-invocation.d.ts +2 -0
- package/dist/src/alpha6/literal-test-invocation.js +106 -0
- package/dist/src/alpha6/literal-test-invocation.js.map +1 -0
- package/dist/src/alpha6/milestone.d.ts +2 -0
- package/dist/src/alpha6/milestone.js +10 -1
- package/dist/src/alpha6/milestone.js.map +1 -1
- package/dist/src/alpha6/navigation-dirty-carryover.d.ts +11 -0
- package/dist/src/alpha6/navigation-dirty-carryover.js +130 -0
- package/dist/src/alpha6/navigation-dirty-carryover.js.map +1 -0
- package/dist/src/alpha6/plan-integrity.js +7 -5
- package/dist/src/alpha6/plan-integrity.js.map +1 -1
- package/dist/src/alpha6/remediation.d.ts +6 -21
- package/dist/src/alpha6/remediation.js +161 -476
- package/dist/src/alpha6/remediation.js.map +1 -1
- package/dist/src/alpha6/root-cause-replan-carryover.d.ts +4 -1
- package/dist/src/alpha6/root-cause-replan-carryover.js +29 -5
- package/dist/src/alpha6/root-cause-replan-carryover.js.map +1 -1
- package/dist/src/beta1/project-transaction.d.ts +3 -1
- package/dist/src/beta1/project-transaction.js +6 -2
- package/dist/src/beta1/project-transaction.js.map +1 -1
- package/dist/src/checks/runner.d.ts +26 -0
- package/dist/src/checks/runner.js +223 -0
- package/dist/src/checks/runner.js.map +1 -0
- package/dist/src/checks/task-sync.d.ts +10 -0
- package/dist/src/checks/task-sync.js +41 -0
- package/dist/src/checks/task-sync.js.map +1 -0
- package/dist/src/checks/worker.d.ts +1 -0
- package/dist/src/checks/worker.js +154 -0
- package/dist/src/checks/worker.js.map +1 -0
- package/dist/src/cli-actions.d.ts +2 -2
- package/dist/src/cli-actions.js +3 -15
- package/dist/src/cli-actions.js.map +1 -1
- package/dist/src/cli.js +14 -98
- package/dist/src/cli.js.map +1 -1
- package/dist/src/contracts.d.ts +35 -32
- package/dist/src/dependency-provenance.d.ts +2 -2
- package/dist/src/dependency-provenance.js +12 -69
- package/dist/src/dependency-provenance.js.map +1 -1
- package/dist/src/domain/base-sync-conflict.d.ts +13 -0
- package/dist/src/domain/base-sync-conflict.js +32 -0
- package/dist/src/domain/base-sync-conflict.js.map +1 -0
- package/dist/src/domain/step-start-admission.d.ts +4 -0
- package/dist/src/domain/step-start-admission.js +9 -0
- package/dist/src/domain/step-start-admission.js.map +1 -0
- package/dist/src/domain/virgin-registration.d.ts +3 -0
- package/dist/src/domain/virgin-registration.js +65 -0
- package/dist/src/domain/virgin-registration.js.map +1 -0
- package/dist/src/errors.d.ts +1 -1
- package/dist/src/errors.js.map +1 -1
- package/dist/src/gateway-handshake.js +0 -8
- package/dist/src/gateway-handshake.js.map +1 -1
- package/dist/src/git.d.ts +3 -1
- package/dist/src/git.js +41 -14
- package/dist/src/git.js.map +1 -1
- package/dist/src/graph.js +25 -3
- package/dist/src/graph.js.map +1 -1
- package/dist/src/index.d.ts +1 -0
- package/dist/src/navigation-actions.d.ts +9 -0
- package/dist/src/navigation-actions.js +72 -0
- package/dist/src/navigation-actions.js.map +1 -0
- package/dist/src/navigation-update-artifact.d.ts +5 -0
- package/dist/src/navigation-update-artifact.js +227 -0
- package/dist/src/navigation-update-artifact.js.map +1 -0
- package/dist/src/navigation-update.d.ts +39 -0
- package/dist/src/navigation-update.js +82 -0
- package/dist/src/navigation-update.js.map +1 -0
- package/dist/src/observation.js +52 -29
- package/dist/src/observation.js.map +1 -1
- package/dist/src/observed-routes.js +5 -6
- package/dist/src/observed-routes.js.map +1 -1
- package/dist/src/pending-review-update.d.ts +0 -13
- package/dist/src/pending-review-update.js +1 -6
- package/dist/src/pending-review-update.js.map +1 -1
- package/dist/src/reviewer.d.ts +1 -1
- package/dist/src/reviewer.js +20 -17
- package/dist/src/reviewer.js.map +1 -1
- package/dist/src/runtime.d.ts +3 -0
- package/dist/src/runtime.js +6 -0
- package/dist/src/runtime.js.map +1 -0
- package/dist/src/state/corrective-replan-executor.d.ts +10 -0
- package/dist/src/state/corrective-replan-executor.js +37 -1
- package/dist/src/state/corrective-replan-executor.js.map +1 -1
- package/dist/src/state/corrective-replan-public-schema.js +15 -2
- package/dist/src/state/corrective-replan-public-schema.js.map +1 -1
- package/dist/src/state/corrective-replan-public.js +1 -1
- package/dist/src/state/corrective-replan-public.js.map +1 -1
- package/dist/src/state/corrective-replan-transaction.d.ts +1 -0
- package/dist/src/state/corrective-replan-transaction.js +12 -11
- package/dist/src/state/corrective-replan-transaction.js.map +1 -1
- package/dist/src/state/corrective-yield-transaction.d.ts +1 -0
- package/dist/src/state/corrective-yield-transaction.js +11 -10
- package/dist/src/state/corrective-yield-transaction.js.map +1 -1
- package/dist/src/state/lock.d.ts +4 -0
- package/dist/src/state/lock.js +21 -0
- package/dist/src/state/lock.js.map +1 -1
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/dist/src/version.js.map +1 -1
- package/dist/src/workflow-blocker-route.d.ts +8 -0
- package/dist/src/workflow-blocker-route.js +106 -0
- package/dist/src/workflow-blocker-route.js.map +1 -0
- package/dist/src/workflow.d.ts +48 -63
- package/dist/src/workflow.js +666 -1460
- package/dist/src/workflow.js.map +1 -1
- package/docs/autonomy-guardrails.md +26 -304
- package/docs/decisions.md +14 -112
- package/docs/development-flow.md +35 -238
- package/docs/project-memory.md +31 -50
- package/docs/release-app-evidence.md +190 -0
- package/docs/release.md +95 -388
- package/docs/updating-existing-project.md +19 -717
- package/package.json +11 -13
- package/plugins/codex-workflow-gateway/.codex-plugin/plugin.json +2 -2
- package/plugins/codex-workflow-gateway/references/chat-dispatch.md +73 -197
- package/plugins/codex-workflow-gateway/references/codebase-memory-routing.md +57 -0
- package/plugins/codex-workflow-gateway/references/protocol.md +42 -445
- package/plugins/codex-workflow-gateway/scripts/chat-dispatch.mjs +7 -1
- package/plugins/codex-workflow-gateway/scripts/chat-model-policy.mjs +10 -8
- package/plugins/codex-workflow-gateway/scripts/chat-registry.mjs +23 -5
- package/plugins/codex-workflow-gateway/skills/codex-workflow-gateway/SKILL.md +66 -757
- package/references/state-machine.md +4 -4
- package/roles/technical-planner.md +1 -1
- package/schemas/corrective-decision-event.schema.json +3 -1
- package/schemas/project-knowledge-map.schema.json +3 -1
- package/schemas/remediation-event.schema.json +10 -1
- package/schemas/task.schema.json +46 -2
- package/schemas/transition-payloads.schema.json +16 -1
- package/src/alpha6/adoption.ts +1123 -0
- package/src/alpha6/captured-check-evidence.ts +132 -0
- package/src/alpha6/check-support-anchor.ts +128 -0
- package/src/alpha6/component-owner.ts +168 -0
- package/src/alpha6/corrective-decision-boundary.ts +39 -0
- package/src/alpha6/downstream-proof.ts +520 -0
- package/src/alpha6/failed-step-planning-recovery.ts +88 -0
- package/src/alpha6/handoff.ts +1443 -0
- package/src/alpha6/journal.ts +473 -0
- package/src/alpha6/literal-test-invocation.ts +84 -0
- package/src/alpha6/mechanical-feasibility.ts +488 -0
- package/src/alpha6/milestone.ts +2192 -0
- package/src/alpha6/navigation-dirty-carryover.ts +154 -0
- package/src/alpha6/npm-check-contract.ts +47 -0
- package/src/alpha6/plan-integrity.ts +298 -0
- package/src/alpha6/plan-risk.ts +1480 -0
- package/src/alpha6/preexecution-replan.ts +187 -0
- package/src/alpha6/remediation-cause.ts +98 -0
- package/src/alpha6/remediation.ts +2438 -0
- package/src/alpha6/review.ts +1198 -0
- package/src/alpha6/root-cause-replan-carryover.ts +491 -0
- package/src/alpha6/store-sidecars.ts +335 -0
- package/src/alpha7/autonomy.ts +411 -0
- package/src/alpha7/corrective-recovery.ts +1332 -0
- package/src/artifacts.ts +130 -0
- package/src/beta1/project-transaction.ts +355 -0
- package/src/change-explanation.ts +153 -0
- package/src/checks/runner.ts +245 -0
- package/src/checks/task-sync.ts +41 -0
- package/src/checks/worker.ts +149 -0
- package/src/cli-actions.ts +107 -0
- package/src/cli.ts +1457 -0
- package/src/contracts.ts +1494 -0
- package/src/credential-output.ts +89 -0
- package/src/credential-transport.ts +215 -0
- package/src/delegation.ts +190 -0
- package/src/dependency-provenance.ts +472 -0
- package/src/diagnostics.ts +93 -0
- package/src/domain/base-sync-conflict.ts +35 -0
- package/src/domain/completed-step-carryover.ts +76 -0
- package/src/domain/discovery.ts +27 -0
- package/src/domain/plan-semantics.ts +58 -0
- package/src/domain/step-start-admission.ts +10 -0
- package/src/domain/validation.ts +177 -0
- package/src/domain/virgin-registration.ts +46 -0
- package/src/errors.ts +24 -0
- package/src/fs-utils.ts +61 -0
- package/src/gateway-handshake.ts +95 -0
- package/src/git.ts +183 -0
- package/src/graph.ts +342 -0
- package/src/historical-step-provenance.ts +136 -0
- package/src/index.ts +23 -0
- package/src/lifecycle/canonical-hash.ts +28 -0
- package/src/lifecycle/catalog.ts +202 -0
- package/src/lifecycle/compiler-inspection.ts +29 -0
- package/src/lifecycle/core-static-readiness.ts +132 -0
- package/src/lifecycle/corrective-replan-authority.ts +136 -0
- package/src/lifecycle/corrective-replan-binding-manifest.ts +51 -0
- package/src/lifecycle/corrective-replan-credential-core.ts +408 -0
- package/src/lifecycle/corrective-replan-credential-schema.ts +54 -0
- package/src/lifecycle/corrective-replan-credentials.ts +48 -0
- package/src/lifecycle/corrective-replan.ts +843 -0
- package/src/lifecycle/evaluator.ts +48 -0
- package/src/lifecycle/fingerprint.ts +488 -0
- package/src/lifecycle/immutable.ts +8 -0
- package/src/lifecycle/implementation-table.ts +118 -0
- package/src/lifecycle/index.ts +8 -0
- package/src/lifecycle/schema-artifact.ts +263 -0
- package/src/lifecycle/semantic-registry.ts +572 -0
- package/src/lifecycle/types.ts +838 -0
- package/src/memory.ts +273 -0
- package/src/migration.ts +161 -0
- package/src/navigation-actions.ts +70 -0
- package/src/navigation-update-artifact.ts +198 -0
- package/src/navigation-update.ts +121 -0
- package/src/observation.ts +225 -0
- package/src/observed-routes.ts +660 -0
- package/src/operational-contract.ts +125 -0
- package/src/pending-review-update.ts +175 -0
- package/src/repository.ts +99 -0
- package/src/reviewer.ts +1879 -0
- package/src/runtime.ts +6 -0
- package/src/state/corrective-replan-executor.ts +818 -0
- package/src/state/corrective-replan-public-schema.ts +83 -0
- package/src/state/corrective-replan-public.ts +908 -0
- package/src/state/corrective-replan-transaction.ts +949 -0
- package/src/state/corrective-yield-executor.ts +327 -0
- package/src/state/corrective-yield-transaction.ts +730 -0
- package/src/state/lock.ts +902 -0
- package/src/state/store.ts +567 -0
- package/src/transition-core.ts +330 -0
- package/src/ulid.ts +24 -0
- package/src/version.ts +2 -0
- package/src/workflow-blocker-route.ts +109 -0
- package/src/workflow.ts +10172 -0
- package/docs/alpha7.1-implementation-brief.md +0 -268
- package/docs/alpha7.2-corrective-context-refresh-brief.md +0 -484
- package/docs/alpha7.2.1-remediation-recovery-brief.md +0 -86
- package/docs/beta1-stabilization-brief.md +0 -165
- package/docs/beta11-plan-integrity-recovery-brief.md +0 -38
- package/docs/beta13.2-signal-review-recovery.md +0 -38
- package/docs/beta2-initial-assembly-navigation-brief.md +0 -616
- package/docs/change-model.md +0 -118
- package/docs/delegated-approval.md +0 -254
- package/docs/lifecycle/state-machine-stabilization.md +0 -641
- package/docs/pdf/README.md +0 -24
- package/docs/pdf/codex-workflow-v2-architecture-ru.pdf +0 -0
- package/docs/pdf/codex-workflow-v2-chat-only-guide-ru.pdf +0 -0
- package/docs/pdf/codex-workflow-v2-technical-reference-ru.pdf +0 -0
- package/docs/pdf/requirements.txt +0 -1
- package/docs/pdf/sources/codex-workflow-v2-architecture-ru.md +0 -478
- package/docs/pdf/sources/codex-workflow-v2-chat-only-guide-ru.md +0 -506
- package/docs/pdf/sources/codex-workflow-v2-technical-reference-ru.md +0 -778
- package/docs/pending-review-update.md +0 -15
- package/docs/problem-briefs/01-pre-implementation-integrity.md +0 -482
- package/docs/problem-briefs/02-minimal-step-integrity.md +0 -411
- package/docs/problem-briefs/03-minimal-agent-context-integrity.md +0 -358
- package/docs/problem-briefs/04-task-dependency-and-structural-replacement-integrity.md +0 -573
- package/docs/problem-briefs/BRIEF-TEMPLATE.md +0 -56
- package/docs/problem-briefs/README.md +0 -120
- package/docs/problem-briefs/evidence/p01-mechanical-feasibility-corpus.md +0 -90
- package/docs/problem-briefs/evidence/signal-v4-pre-m3-replay.md +0 -246
- package/docs/split-required-recovery.md +0 -47
- package/docs/stable-release-defect-register.md +0 -730
- package/docs/validation-report.md +0 -182
- package/scripts/generate-pdf-docs.py +0 -524
- package/scripts/run-pdf-docs.mjs +0 -62
|
@@ -0,0 +1,2192 @@
|
|
|
1
|
+
import { existsSync, lstatSync, readFileSync, realpathSync, readdirSync, rmSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import type {
|
|
4
|
+
AuditedRemediationEvidence,
|
|
5
|
+
AuthorizationEvent,
|
|
6
|
+
MilestoneFinalAcceptanceGate,
|
|
7
|
+
MilestoneMembership,
|
|
8
|
+
MilestonePlanInput,
|
|
9
|
+
MilestonePlanMembershipInput,
|
|
10
|
+
MilestoneScopeChangeEvent,
|
|
11
|
+
MilestoneScopeSnapshot,
|
|
12
|
+
MilestoneState,
|
|
13
|
+
MilestoneTransactionJournal,
|
|
14
|
+
TaskState,
|
|
15
|
+
} from '../contracts.js';
|
|
16
|
+
import { WorkflowError } from '../errors.js';
|
|
17
|
+
import type { FileStateStore } from '../state/store.js';
|
|
18
|
+
import { createEntityId } from '../ulid.js';
|
|
19
|
+
import { readMilestoneAutonomyEvents } from '../alpha7/autonomy.js';
|
|
20
|
+
import {
|
|
21
|
+
createJournalSnapshotFromContent,
|
|
22
|
+
captureJournalTargetSnapshot,
|
|
23
|
+
listMilestoneTransactionJournals,
|
|
24
|
+
readJournalSnapshotContent,
|
|
25
|
+
removeMilestoneTransactionJournal,
|
|
26
|
+
resolveMilestoneTransactionTargetPath,
|
|
27
|
+
updateMilestoneTransactionJournalPhase,
|
|
28
|
+
writeMilestoneTransactionJournal,
|
|
29
|
+
} from './journal.js';
|
|
30
|
+
import {
|
|
31
|
+
assertHash64,
|
|
32
|
+
assertNoSymlinkPathComponents,
|
|
33
|
+
canonicalJsonStringify,
|
|
34
|
+
computeSidecarEventHash,
|
|
35
|
+
prepareSidecarAppend,
|
|
36
|
+
sha256Hex,
|
|
37
|
+
} from './store-sidecars.js';
|
|
38
|
+
|
|
39
|
+
type ReadTask = (taskId: string) => TaskState;
|
|
40
|
+
type ListTasks = () => TaskState[];
|
|
41
|
+
|
|
42
|
+
const SCOPE_CHANGE_SIDECAR = 'scope-change-events.jsonl';
|
|
43
|
+
const INITIAL_PLAN_TARGETS = ['state.json', 'plan.json'] as const;
|
|
44
|
+
const MILESTONE_TARGETS = ['state.json', 'plan.json', SCOPE_CHANGE_SIDECAR] as const;
|
|
45
|
+
const SCOPE_CHANGE_ALLOWED_STATUSES = new Set<MilestoneState['status']>([
|
|
46
|
+
'planning',
|
|
47
|
+
'awaiting_execution_authorization',
|
|
48
|
+
'active',
|
|
49
|
+
'blocked',
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
interface MembershipIntegrityOptions {
|
|
53
|
+
allowRepairableRequiredCancelled?: boolean;
|
|
54
|
+
allowRepairableReverseOrphans?: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface MilestoneInitialAssemblyAssessment {
|
|
58
|
+
eligible: boolean;
|
|
59
|
+
orphanTaskIds: string[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface NormalizedMilestonePlan {
|
|
63
|
+
outcome: string;
|
|
64
|
+
successSignal: string;
|
|
65
|
+
acceptance: string[];
|
|
66
|
+
memberships: MilestonePlanMembershipInput[];
|
|
67
|
+
checks: string[];
|
|
68
|
+
taskIds: string[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface PersistedMilestonePlan {
|
|
72
|
+
outcome: string;
|
|
73
|
+
successSignal: string;
|
|
74
|
+
acceptance: string[];
|
|
75
|
+
memberships: MilestoneMembership[];
|
|
76
|
+
checks: string[];
|
|
77
|
+
taskIds: string[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface MilestoneTaskDependencyAssessment {
|
|
81
|
+
dependencyState: 'canonical' | 'legacy-unknown';
|
|
82
|
+
dependsOnTaskIds: string[];
|
|
83
|
+
unsatisfiedPredecessors: Array<{
|
|
84
|
+
taskId: string;
|
|
85
|
+
disposition: MilestoneMembership['disposition'] | 'missing';
|
|
86
|
+
status: TaskState['status'] | 'missing';
|
|
87
|
+
}>;
|
|
88
|
+
runnable: boolean;
|
|
89
|
+
blockReason: string | null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface PreparedMilestoneScopeChange {
|
|
93
|
+
milestoneId: string;
|
|
94
|
+
expectedRevision: number;
|
|
95
|
+
actor: string;
|
|
96
|
+
normalizedPlan: NormalizedMilestonePlan;
|
|
97
|
+
authorizations: AuthorizationEvent[];
|
|
98
|
+
candidatePlanHash: string;
|
|
99
|
+
candidateMembershipRevision: number;
|
|
100
|
+
after: MilestoneScopeSnapshot;
|
|
101
|
+
confirmationCodeBindingHash: string;
|
|
102
|
+
confirmationCode: string;
|
|
103
|
+
authorizationMode: 'human' | 'milestone-autonomy' | 'audited-remediation';
|
|
104
|
+
autonomyContractEventHash: string | null;
|
|
105
|
+
remediationEvidence: AuditedRemediationEvidence | null;
|
|
106
|
+
resultingStatus: MilestoneState['status'];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface PreparedMilestoneScopeChangeBundle {
|
|
110
|
+
state: MilestoneState;
|
|
111
|
+
stateContent: string;
|
|
112
|
+
planContent: string;
|
|
113
|
+
sidecarContent: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Produces the exact three-file Milestone commit without writing it. Beta transactions use this
|
|
118
|
+
* to couple a scope change to Task/Discovery changes under one outer recovery journal.
|
|
119
|
+
*/
|
|
120
|
+
export function prepareMilestoneScopeChangeBundle(options: {
|
|
121
|
+
store: FileStateStore;
|
|
122
|
+
milestone: MilestoneState;
|
|
123
|
+
prepared: PreparedMilestoneScopeChange;
|
|
124
|
+
now: Date;
|
|
125
|
+
readTask: ReadTask;
|
|
126
|
+
}): PreparedMilestoneScopeChangeBundle {
|
|
127
|
+
const { store, milestone, prepared, now, readTask } = options;
|
|
128
|
+
const planContent = renderMilestonePlanArtifact(prepared.normalizedPlan);
|
|
129
|
+
const planHash = sha256Hex(planContent);
|
|
130
|
+
if (planHash !== prepared.candidatePlanHash) {
|
|
131
|
+
throw new WorkflowError('TRANSITION_BLOCKED', 'Milestone scope change candidate drifted before bundle preparation.', {
|
|
132
|
+
expectedPlanHash: prepared.candidatePlanHash,
|
|
133
|
+
actualPlanHash: planHash,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
const currentEvents = readMilestoneScopeChangeEvents(store, milestone.projectId, milestone.id);
|
|
137
|
+
const sidecar = prepareSidecarAppend<MilestoneScopeChangeEvent>(currentEvents, createMilestoneScopeChangeEvent({
|
|
138
|
+
milestone,
|
|
139
|
+
normalizedPlan: prepared.normalizedPlan,
|
|
140
|
+
actor: prepared.actor,
|
|
141
|
+
now,
|
|
142
|
+
previousEventHash: currentEvents.at(-1)?.eventHash ?? null,
|
|
143
|
+
candidatePlanHash: planHash,
|
|
144
|
+
candidateMembershipRevision: prepared.candidateMembershipRevision,
|
|
145
|
+
confirmationCodeBindingHash: prepared.confirmationCodeBindingHash,
|
|
146
|
+
authorizationMode: prepared.authorizationMode,
|
|
147
|
+
autonomyContractEventHash: prepared.autonomyContractEventHash,
|
|
148
|
+
remediationEvidence: prepared.remediationEvidence,
|
|
149
|
+
}), { validateEvent: (candidate) => validateMilestoneScopeChangeEvent(candidate, milestone.id) });
|
|
150
|
+
const state: MilestoneState = {
|
|
151
|
+
...milestone,
|
|
152
|
+
revision: milestone.revision + 1,
|
|
153
|
+
updatedAt: now.toISOString(),
|
|
154
|
+
status: prepared.resultingStatus,
|
|
155
|
+
outcome: prepared.normalizedPlan.outcome,
|
|
156
|
+
successSignal: prepared.normalizedPlan.successSignal,
|
|
157
|
+
acceptance: prepared.normalizedPlan.acceptance,
|
|
158
|
+
taskIds: prepared.normalizedPlan.taskIds,
|
|
159
|
+
memberships: prepared.normalizedPlan.memberships,
|
|
160
|
+
checks: prepared.normalizedPlan.checks,
|
|
161
|
+
membershipRevision: prepared.candidateMembershipRevision,
|
|
162
|
+
authorizations: prepared.authorizations,
|
|
163
|
+
planHash,
|
|
164
|
+
resultHash: null,
|
|
165
|
+
evidenceHash: null,
|
|
166
|
+
acceptedHead: null,
|
|
167
|
+
cancellationReason: null,
|
|
168
|
+
};
|
|
169
|
+
assertMilestoneMembershipIntegrity(state, readTask);
|
|
170
|
+
return {
|
|
171
|
+
state,
|
|
172
|
+
stateContent: `${JSON.stringify(state, null, 2)}\n`,
|
|
173
|
+
planContent,
|
|
174
|
+
sidecarContent: sidecar.content,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function normalizeMilestonePlan(plan: MilestonePlanInput): NormalizedMilestonePlan {
|
|
179
|
+
const normalized: NormalizedMilestonePlan = {
|
|
180
|
+
outcome: plan.outcome.trim(),
|
|
181
|
+
successSignal: plan.successSignal.trim(),
|
|
182
|
+
acceptance: [...plan.acceptance],
|
|
183
|
+
memberships: plan.memberships.map((membership, index) => {
|
|
184
|
+
if (!membership || typeof membership !== 'object' || Array.isArray(membership)) {
|
|
185
|
+
throw new WorkflowError('INVALID_ARGUMENT', 'Milestone membership must be an object.', {
|
|
186
|
+
membershipIndex: index,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
if (typeof membership.taskId !== 'string' || !membership.taskId.trim()) {
|
|
190
|
+
throw new WorkflowError('INVALID_ARGUMENT', 'Milestone membership requires a Task ID.', {
|
|
191
|
+
membershipIndex: index,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
if (typeof membership.reason !== 'string') {
|
|
195
|
+
throw new WorkflowError('INVALID_ARGUMENT', 'Milestone membership reason must be a string.', {
|
|
196
|
+
membershipIndex: index,
|
|
197
|
+
taskId: membership.taskId,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
if (!Array.isArray(membership.dependsOnTaskIds)) {
|
|
201
|
+
throw new WorkflowError('INVALID_ARGUMENT', 'Every Milestone membership must declare dependsOnTaskIds explicitly.', {
|
|
202
|
+
membershipIndex: index,
|
|
203
|
+
taskId: membership.taskId,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
if (membership.dependsOnTaskIds.some((taskId) => typeof taskId !== 'string' || !taskId.trim())) {
|
|
207
|
+
throw new WorkflowError('INVALID_ARGUMENT', 'Milestone dependency IDs must be non-empty strings.', {
|
|
208
|
+
membershipIndex: index,
|
|
209
|
+
taskId: membership.taskId,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
taskId: membership.taskId.trim(),
|
|
214
|
+
disposition: membership.disposition,
|
|
215
|
+
reason: membership.reason.trim(),
|
|
216
|
+
dependsOnTaskIds: [...membership.dependsOnTaskIds].map((taskId) => taskId.trim()).sort(),
|
|
217
|
+
}; }),
|
|
218
|
+
checks: [...plan.checks],
|
|
219
|
+
taskIds: plan.memberships.map((membership) => membership.taskId.trim()),
|
|
220
|
+
};
|
|
221
|
+
assertCanonicalMilestoneDependencyGraph(normalized.memberships, 'INVALID_ARGUMENT', 'Milestone Plan');
|
|
222
|
+
return normalized;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function assessMilestoneTaskDependencies(
|
|
226
|
+
milestone: MilestoneState,
|
|
227
|
+
taskId: string,
|
|
228
|
+
readTask: ReadTask,
|
|
229
|
+
): MilestoneTaskDependencyAssessment {
|
|
230
|
+
const membership = milestone.memberships.find((candidate) => candidate.taskId === taskId);
|
|
231
|
+
if (!membership) {
|
|
232
|
+
return {
|
|
233
|
+
dependencyState: milestone.memberships.every((candidate) => Array.isArray(candidate.dependsOnTaskIds))
|
|
234
|
+
? 'canonical'
|
|
235
|
+
: 'legacy-unknown',
|
|
236
|
+
dependsOnTaskIds: [],
|
|
237
|
+
unsatisfiedPredecessors: [],
|
|
238
|
+
runnable: false,
|
|
239
|
+
blockReason: `Task ${taskId} is not a Milestone member.`,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
if (milestone.memberships.some((candidate) => !Array.isArray(candidate.dependsOnTaskIds))) {
|
|
243
|
+
return {
|
|
244
|
+
dependencyState: 'legacy-unknown',
|
|
245
|
+
dependsOnTaskIds: [],
|
|
246
|
+
unsatisfiedPredecessors: [],
|
|
247
|
+
runnable: false,
|
|
248
|
+
blockReason: 'Milestone dependency graph is unknown for this legacy nonterminal Plan.',
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
assertCanonicalMilestoneDependencyGraph(
|
|
252
|
+
milestone.memberships as MilestonePlanMembershipInput[],
|
|
253
|
+
'STATE_CORRUPT',
|
|
254
|
+
`Milestone ${milestone.id}`,
|
|
255
|
+
);
|
|
256
|
+
const dependsOnTaskIds = [...(membership.dependsOnTaskIds ?? [])];
|
|
257
|
+
const unsatisfiedPredecessors = dependsOnTaskIds.flatMap((predecessorTaskId) => {
|
|
258
|
+
const predecessorMembership = milestone.memberships.find((candidate) => candidate.taskId === predecessorTaskId);
|
|
259
|
+
let predecessor: TaskState | null = null;
|
|
260
|
+
try {
|
|
261
|
+
predecessor = readTask(predecessorTaskId);
|
|
262
|
+
} catch (error) {
|
|
263
|
+
if (!(error instanceof WorkflowError) || error.code !== 'NOT_FOUND') throw error;
|
|
264
|
+
}
|
|
265
|
+
if (predecessorMembership?.disposition === 'required' && predecessor?.status === 'merged') return [];
|
|
266
|
+
return [{
|
|
267
|
+
taskId: predecessorTaskId,
|
|
268
|
+
disposition: predecessorMembership?.disposition ?? 'missing' as const,
|
|
269
|
+
status: predecessor?.status ?? 'missing' as const,
|
|
270
|
+
}];
|
|
271
|
+
});
|
|
272
|
+
const runnable = membership.disposition === 'required' && unsatisfiedPredecessors.length === 0;
|
|
273
|
+
return {
|
|
274
|
+
dependencyState: 'canonical',
|
|
275
|
+
dependsOnTaskIds,
|
|
276
|
+
unsatisfiedPredecessors,
|
|
277
|
+
runnable,
|
|
278
|
+
blockReason: membership.disposition !== 'required'
|
|
279
|
+
? `Milestone membership is ${membership.disposition}, not required.`
|
|
280
|
+
: unsatisfiedPredecessors.length > 0
|
|
281
|
+
? 'Required predecessor Tasks have not all merged.'
|
|
282
|
+
: null,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export function assertMilestoneDependenciesDeclared(milestone: MilestoneState): void {
|
|
287
|
+
if (milestone.memberships.some((membership) => !Array.isArray(membership.dependsOnTaskIds))) {
|
|
288
|
+
throw new WorkflowError('TRANSITION_BLOCKED', `Milestone ${milestone.id} has no canonical dependency graph.`, {
|
|
289
|
+
diagnosticCode: 'MILESTONE_DEPENDENCIES_UNKNOWN',
|
|
290
|
+
milestoneId: milestone.id,
|
|
291
|
+
membershipRevision: milestone.membershipRevision,
|
|
292
|
+
requiredAction: 'Apply an explicit Milestone scope change containing dependsOnTaskIds for every membership.',
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
assertCanonicalMilestoneDependencyGraph(
|
|
296
|
+
milestone.memberships as MilestonePlanMembershipInput[],
|
|
297
|
+
'STATE_CORRUPT',
|
|
298
|
+
`Milestone ${milestone.id}`,
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function assertMilestoneTaskRunnable(
|
|
303
|
+
milestone: MilestoneState,
|
|
304
|
+
taskId: string,
|
|
305
|
+
readTask: ReadTask,
|
|
306
|
+
): MilestoneTaskDependencyAssessment {
|
|
307
|
+
const assessment = assessMilestoneTaskDependencies(milestone, taskId, readTask);
|
|
308
|
+
if (milestone.status !== 'active' || !assessment.runnable) {
|
|
309
|
+
const diagnosticCode = assessment.dependencyState === 'legacy-unknown'
|
|
310
|
+
? 'MILESTONE_DEPENDENCIES_UNKNOWN'
|
|
311
|
+
: milestone.status !== 'active'
|
|
312
|
+
? 'MILESTONE_NOT_ACTIVE'
|
|
313
|
+
: assessment.unsatisfiedPredecessors.length > 0
|
|
314
|
+
? 'MILESTONE_PREDECESSORS_UNSATISFIED'
|
|
315
|
+
: 'MILESTONE_MEMBERSHIP_NOT_REQUIRED';
|
|
316
|
+
throw new WorkflowError('TRANSITION_BLOCKED', `Task ${taskId} is not runnable in Milestone ${milestone.id}.`, {
|
|
317
|
+
diagnosticCode,
|
|
318
|
+
milestoneId: milestone.id,
|
|
319
|
+
milestoneStatus: milestone.status,
|
|
320
|
+
taskId,
|
|
321
|
+
...assessment,
|
|
322
|
+
requiredAction: assessment.dependencyState === 'legacy-unknown'
|
|
323
|
+
? 'Apply an explicit Milestone scope change containing dependsOnTaskIds for every membership.'
|
|
324
|
+
: milestone.status !== 'active'
|
|
325
|
+
? 'Authorize the current Milestone Plan before continuing this Task.'
|
|
326
|
+
: assessment.unsatisfiedPredecessors.length > 0
|
|
327
|
+
? 'Merge every required predecessor Task before continuing this Task.'
|
|
328
|
+
: 'Restore a required Milestone membership before continuing this Task.',
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
return assessment;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export function readMilestoneWithIntegrity(
|
|
335
|
+
store: FileStateStore,
|
|
336
|
+
projectId: string,
|
|
337
|
+
milestoneId: string,
|
|
338
|
+
readTask: ReadTask,
|
|
339
|
+
listTasks?: ListTasks,
|
|
340
|
+
): MilestoneState {
|
|
341
|
+
recoverMilestoneScopeChangeTransactions(store, projectId, milestoneId, readTask);
|
|
342
|
+
const milestone = store.readMilestone(projectId, milestoneId);
|
|
343
|
+
assertMilestonePlanAgreement(store, milestone);
|
|
344
|
+
const initialAssembly = assessMilestoneInitialAssembly({
|
|
345
|
+
store,
|
|
346
|
+
projectId,
|
|
347
|
+
milestone,
|
|
348
|
+
...(listTasks ? { listTasks } : {}),
|
|
349
|
+
});
|
|
350
|
+
if (initialAssembly.eligible) return milestone;
|
|
351
|
+
assertMilestoneMembershipIntegrity(milestone, readTask, {}, listTasks);
|
|
352
|
+
return milestone;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Read-only integrity validation for observation APIs. Pending journals must be assessed first. */
|
|
356
|
+
export function inspectMilestoneWithIntegrity(
|
|
357
|
+
store: FileStateStore,
|
|
358
|
+
projectId: string,
|
|
359
|
+
milestoneId: string,
|
|
360
|
+
readTask: ReadTask,
|
|
361
|
+
listTasks?: ListTasks,
|
|
362
|
+
): MilestoneState {
|
|
363
|
+
const milestone = store.readMilestone(projectId, milestoneId);
|
|
364
|
+
assertMilestonePlanAgreement(store, milestone);
|
|
365
|
+
const initialAssembly = assessMilestoneInitialAssembly({
|
|
366
|
+
store,
|
|
367
|
+
projectId,
|
|
368
|
+
milestone,
|
|
369
|
+
...(listTasks ? { listTasks } : {}),
|
|
370
|
+
});
|
|
371
|
+
if (initialAssembly.eligible) return milestone;
|
|
372
|
+
assertMilestoneMembershipIntegrity(milestone, readTask, {
|
|
373
|
+
allowRepairableReverseOrphans: !['accepted', 'cancelled'].includes(milestone.status)
|
|
374
|
+
&& milestone.planHash !== null && milestone.membershipRevision > 0 && milestone.memberships.length > 0,
|
|
375
|
+
}, listTasks);
|
|
376
|
+
return milestone;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Read-only gap assessment; observation has already verified all other integrity constraints. */
|
|
380
|
+
export function assessMilestoneScopeCompletion(milestone: MilestoneState, tasks: TaskState[]): string[] {
|
|
381
|
+
const classified = new Set(milestone.memberships.map((membership) => membership.taskId));
|
|
382
|
+
return tasks.filter((task) => task.milestoneId === milestone.id && task.status !== 'cancelled'
|
|
383
|
+
&& !classified.has(task.id)).map((task) => task.id);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export function readMilestoneForScopeChange(
|
|
387
|
+
store: FileStateStore,
|
|
388
|
+
projectId: string,
|
|
389
|
+
milestoneId: string,
|
|
390
|
+
readTask: ReadTask,
|
|
391
|
+
listTasks?: ListTasks,
|
|
392
|
+
): MilestoneState {
|
|
393
|
+
recoverMilestoneScopeChangeTransactions(store, projectId, milestoneId, readTask);
|
|
394
|
+
const milestone = store.readMilestone(projectId, milestoneId);
|
|
395
|
+
assertMilestonePlanAgreement(store, milestone);
|
|
396
|
+
const initialAssembly = assessMilestoneInitialAssembly({
|
|
397
|
+
store,
|
|
398
|
+
projectId,
|
|
399
|
+
milestone,
|
|
400
|
+
...(listTasks ? { listTasks } : {}),
|
|
401
|
+
});
|
|
402
|
+
if (initialAssembly.eligible) return milestone;
|
|
403
|
+
assertMilestoneMembershipIntegrity(
|
|
404
|
+
milestone,
|
|
405
|
+
readTask,
|
|
406
|
+
{ allowRepairableRequiredCancelled: true, allowRepairableReverseOrphans: true },
|
|
407
|
+
listTasks,
|
|
408
|
+
);
|
|
409
|
+
return milestone;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
export function listMilestonesWithIntegrity(
|
|
413
|
+
store: FileStateStore,
|
|
414
|
+
projectId: string,
|
|
415
|
+
readTask: ReadTask,
|
|
416
|
+
listTasks?: ListTasks,
|
|
417
|
+
): MilestoneState[] {
|
|
418
|
+
const milestonesRoot = path.join(store.projectRoot(projectId), 'milestones');
|
|
419
|
+
if (!existsSync(milestonesRoot)) return [];
|
|
420
|
+
return readdirSync(milestonesRoot, { withFileTypes: true })
|
|
421
|
+
.filter((entry) => entry.isDirectory())
|
|
422
|
+
.map((entry) => readMilestoneWithIntegrity(store, projectId, entry.name, readTask, listTasks))
|
|
423
|
+
.sort((left, right) => left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id));
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export function assessMilestoneInitialAssembly(options: {
|
|
427
|
+
store: FileStateStore;
|
|
428
|
+
projectId: string;
|
|
429
|
+
milestone: MilestoneState;
|
|
430
|
+
listTasks?: ListTasks;
|
|
431
|
+
}): MilestoneInitialAssemblyAssessment {
|
|
432
|
+
const { milestone, listTasks } = options;
|
|
433
|
+
if (!listTasks) return { eligible: false, orphanTaskIds: [] };
|
|
434
|
+
if (
|
|
435
|
+
milestone.status !== 'planning'
|
|
436
|
+
|| milestone.planHash !== null
|
|
437
|
+
|| milestone.membershipRevision !== 0
|
|
438
|
+
|| milestone.memberships.length !== 0
|
|
439
|
+
|| milestone.taskIds.length !== 0
|
|
440
|
+
|| milestone.successSignal !== ''
|
|
441
|
+
|| milestone.checks.length !== 0
|
|
442
|
+
|| milestone.authorizations.length !== 0
|
|
443
|
+
|| milestone.resultHash !== null
|
|
444
|
+
|| milestone.evidenceHash !== null
|
|
445
|
+
|| milestone.acceptedHead !== null
|
|
446
|
+
|| milestone.cancellationReason !== null
|
|
447
|
+
) {
|
|
448
|
+
return { eligible: false, orphanTaskIds: [] };
|
|
449
|
+
}
|
|
450
|
+
const reverseOrphans = listTasks().filter((task) => task.milestoneId === milestone.id);
|
|
451
|
+
if (hasActiveMilestoneScopeChangeTransaction(options.store, options.projectId, milestone.id)) {
|
|
452
|
+
return { eligible: false, orphanTaskIds: reverseOrphans.map((task) => task.id) };
|
|
453
|
+
}
|
|
454
|
+
if (readMilestoneScopeChangeEvents(options.store, options.projectId, milestone.id).length > 0) {
|
|
455
|
+
return { eligible: false, orphanTaskIds: reverseOrphans.map((task) => task.id) };
|
|
456
|
+
}
|
|
457
|
+
if (hasMilestoneAutonomyAuthority(options.store, options.projectId, milestone.id)) {
|
|
458
|
+
return { eligible: false, orphanTaskIds: reverseOrphans.map((task) => task.id) };
|
|
459
|
+
}
|
|
460
|
+
const eligible = reverseOrphans.every((task) => isPristineInitialAssemblyTask(task));
|
|
461
|
+
return { eligible, orphanTaskIds: reverseOrphans.map((task) => task.id) };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
export function assertMilestoneMembershipIntegrity(
|
|
465
|
+
milestone: MilestoneState,
|
|
466
|
+
readTask: ReadTask,
|
|
467
|
+
options: MembershipIntegrityOptions = {},
|
|
468
|
+
listTasks?: ListTasks,
|
|
469
|
+
): void {
|
|
470
|
+
if (!Number.isInteger(milestone.membershipRevision) || milestone.membershipRevision < 0) {
|
|
471
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestone.id} has invalid membershipRevision.`, {
|
|
472
|
+
membershipRevision: milestone.membershipRevision,
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
const membershipTaskIds = milestone.memberships.map((membership) => membership.taskId);
|
|
476
|
+
const uniqueMembershipTaskIds = new Set(membershipTaskIds);
|
|
477
|
+
const uniqueTaskIds = new Set(milestone.taskIds);
|
|
478
|
+
if (uniqueMembershipTaskIds.size !== membershipTaskIds.length) {
|
|
479
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestone.id} memberships contain duplicate Task IDs.`, {
|
|
480
|
+
taskIds: membershipTaskIds,
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
if (uniqueTaskIds.size !== milestone.taskIds.length) {
|
|
484
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestone.id} taskIds contain duplicates.`, {
|
|
485
|
+
taskIds: milestone.taskIds,
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
if (
|
|
489
|
+
membershipTaskIds.length !== milestone.taskIds.length
|
|
490
|
+
|| membershipTaskIds.some((taskId) => !uniqueTaskIds.has(taskId))
|
|
491
|
+
|| milestone.taskIds.some((taskId) => !uniqueMembershipTaskIds.has(taskId))
|
|
492
|
+
) {
|
|
493
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestone.id} taskIds and memberships diverged.`, {
|
|
494
|
+
taskIds: milestone.taskIds,
|
|
495
|
+
membershipTaskIds,
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
const dependencyArraysPresent = milestone.memberships.filter(
|
|
499
|
+
(membership) => Array.isArray(membership.dependsOnTaskIds),
|
|
500
|
+
).length;
|
|
501
|
+
if (dependencyArraysPresent > 0 && dependencyArraysPresent !== milestone.memberships.length) {
|
|
502
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestone.id} mixes canonical and legacy dependency memberships.`, {
|
|
503
|
+
diagnosticCode: 'MILESTONE_DEPENDENCY_GRAPH_PARTIAL',
|
|
504
|
+
canonicalMemberships: dependencyArraysPresent,
|
|
505
|
+
totalMemberships: milestone.memberships.length,
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
if (dependencyArraysPresent === milestone.memberships.length && milestone.memberships.length > 0) {
|
|
509
|
+
assertCanonicalMilestoneDependencyGraph(
|
|
510
|
+
milestone.memberships as MilestonePlanMembershipInput[],
|
|
511
|
+
'STATE_CORRUPT',
|
|
512
|
+
`Milestone ${milestone.id}`,
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
for (const membership of milestone.memberships) {
|
|
516
|
+
const task = readTask(membership.taskId);
|
|
517
|
+
if (task.milestoneId !== milestone.id) {
|
|
518
|
+
throw new WorkflowError('TRANSITION_BLOCKED', `Task ${task.id} is no longer linked to Milestone ${milestone.id}.`, {
|
|
519
|
+
milestoneId: milestone.id,
|
|
520
|
+
taskId: task.id,
|
|
521
|
+
taskMilestoneId: task.milestoneId,
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
if (membership.disposition === 'required' && task.status === 'cancelled' && !options.allowRepairableRequiredCancelled) {
|
|
525
|
+
throw new WorkflowError('TRANSITION_BLOCKED', `Required Milestone Task ${task.id} is cancelled.`, {
|
|
526
|
+
milestoneId: milestone.id,
|
|
527
|
+
taskId: task.id,
|
|
528
|
+
taskStatus: task.status,
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
if (membership.disposition === 'cancelled' && task.status !== 'cancelled') {
|
|
532
|
+
throw new WorkflowError(
|
|
533
|
+
'TRANSITION_BLOCKED',
|
|
534
|
+
`Cancelled Milestone membership requires Task ${task.id} to be cancelled.`,
|
|
535
|
+
{
|
|
536
|
+
milestoneId: milestone.id,
|
|
537
|
+
taskId: task.id,
|
|
538
|
+
disposition: membership.disposition,
|
|
539
|
+
taskStatus: task.status,
|
|
540
|
+
},
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
if (listTasks && !options.allowRepairableReverseOrphans) {
|
|
545
|
+
const reverseOrphans = listTasks()
|
|
546
|
+
.filter((task) => task.milestoneId === milestone.id)
|
|
547
|
+
.filter((task) => task.status !== 'cancelled')
|
|
548
|
+
.filter((task) => !uniqueMembershipTaskIds.has(task.id));
|
|
549
|
+
if (reverseOrphans.length > 0) {
|
|
550
|
+
throw new WorkflowError(
|
|
551
|
+
'TRANSITION_BLOCKED',
|
|
552
|
+
`Milestone ${milestone.id} has linked Tasks that are missing from its classified membership.`,
|
|
553
|
+
{
|
|
554
|
+
diagnosticCode: 'MILESTONE_SCOPE_INCOMPLETE',
|
|
555
|
+
milestoneId: milestone.id,
|
|
556
|
+
orphanTaskIds: reverseOrphans.map((task) => task.id),
|
|
557
|
+
requiredAction: 'Add every linked Task as required, waived, or cancelled through a scope change.',
|
|
558
|
+
},
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function assertCanonicalMilestoneDependencyGraph(
|
|
565
|
+
memberships: readonly MilestonePlanMembershipInput[],
|
|
566
|
+
code: 'INVALID_ARGUMENT' | 'STATE_CORRUPT',
|
|
567
|
+
label: string,
|
|
568
|
+
): void {
|
|
569
|
+
const byTaskId = new Map(memberships.map((membership) => [membership.taskId, membership]));
|
|
570
|
+
if (byTaskId.size !== memberships.length) {
|
|
571
|
+
throw new WorkflowError(code, `${label} memberships contain duplicate Task IDs.`);
|
|
572
|
+
}
|
|
573
|
+
for (const membership of memberships) {
|
|
574
|
+
if (!Array.isArray(membership.dependsOnTaskIds)) {
|
|
575
|
+
throw new WorkflowError(code, `${label} membership ${membership.taskId} is missing dependsOnTaskIds.`);
|
|
576
|
+
}
|
|
577
|
+
if (new Set(membership.dependsOnTaskIds).size !== membership.dependsOnTaskIds.length) {
|
|
578
|
+
throw new WorkflowError(code, `${label} membership ${membership.taskId} contains duplicate dependencies.`, {
|
|
579
|
+
taskId: membership.taskId,
|
|
580
|
+
dependsOnTaskIds: membership.dependsOnTaskIds,
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
if (membership.dependsOnTaskIds.some((taskId) => !taskId)) {
|
|
584
|
+
throw new WorkflowError(code, `${label} membership ${membership.taskId} contains an empty dependency.`);
|
|
585
|
+
}
|
|
586
|
+
const sorted = [...membership.dependsOnTaskIds].sort();
|
|
587
|
+
if (canonicalJsonStringify(sorted) !== canonicalJsonStringify(membership.dependsOnTaskIds)) {
|
|
588
|
+
throw new WorkflowError(code, `${label} membership ${membership.taskId} dependencies are not canonical.`, {
|
|
589
|
+
taskId: membership.taskId,
|
|
590
|
+
expected: sorted,
|
|
591
|
+
actual: membership.dependsOnTaskIds,
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
if (membership.dependsOnTaskIds.includes(membership.taskId)) {
|
|
595
|
+
throw new WorkflowError(code, `${label} membership ${membership.taskId} cannot depend on itself.`);
|
|
596
|
+
}
|
|
597
|
+
for (const predecessorTaskId of membership.dependsOnTaskIds) {
|
|
598
|
+
const predecessor = byTaskId.get(predecessorTaskId);
|
|
599
|
+
if (!predecessor) {
|
|
600
|
+
throw new WorkflowError(code, `${label} membership ${membership.taskId} has a dangling dependency.`, {
|
|
601
|
+
taskId: membership.taskId,
|
|
602
|
+
predecessorTaskId,
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
if (membership.disposition === 'required' && predecessor.disposition !== 'required') {
|
|
606
|
+
throw new WorkflowError(code, `${label} required membership ${membership.taskId} depends on a non-required provider.`, {
|
|
607
|
+
taskId: membership.taskId,
|
|
608
|
+
predecessorTaskId,
|
|
609
|
+
predecessorDisposition: predecessor.disposition,
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
const visiting = new Set<string>();
|
|
615
|
+
const visited = new Set<string>();
|
|
616
|
+
const visit = (taskId: string, path: string[]): void => {
|
|
617
|
+
if (visiting.has(taskId)) {
|
|
618
|
+
const cycleStart = path.indexOf(taskId);
|
|
619
|
+
throw new WorkflowError(code, `${label} dependency graph contains a cycle.`, {
|
|
620
|
+
cycle: [...path.slice(Math.max(cycleStart, 0)), taskId],
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
if (visited.has(taskId)) return;
|
|
624
|
+
visiting.add(taskId);
|
|
625
|
+
const membership = byTaskId.get(taskId)!;
|
|
626
|
+
for (const predecessorTaskId of membership.dependsOnTaskIds) {
|
|
627
|
+
visit(predecessorTaskId, [...path, taskId]);
|
|
628
|
+
}
|
|
629
|
+
visiting.delete(taskId);
|
|
630
|
+
visited.add(taskId);
|
|
631
|
+
};
|
|
632
|
+
for (const membership of memberships) visit(membership.taskId, []);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function hasActiveMilestoneScopeChangeTransaction(
|
|
636
|
+
store: FileStateStore,
|
|
637
|
+
projectId: string,
|
|
638
|
+
milestoneId: string,
|
|
639
|
+
): boolean {
|
|
640
|
+
const milestoneRoot = resolveAlpha6MilestoneRoot(store, projectId, milestoneId);
|
|
641
|
+
return listMilestoneTransactionJournals(milestoneRoot, { expectedMilestoneId: milestoneId })
|
|
642
|
+
.some((journal) => journal.phase === 'prepared' || journal.phase === 'applying');
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function hasMilestoneAutonomyAuthority(
|
|
646
|
+
store: FileStateStore,
|
|
647
|
+
projectId: string,
|
|
648
|
+
milestoneId: string,
|
|
649
|
+
): boolean {
|
|
650
|
+
return readMilestoneAutonomyEvents(store, projectId, milestoneId).length > 0;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function isPristineInitialAssemblyTask(task: TaskState): boolean {
|
|
654
|
+
return task.status === 'planning'
|
|
655
|
+
&& task.workspaceOwner === null
|
|
656
|
+
&& task.taskBranch === null
|
|
657
|
+
&& task.baseCommit === null
|
|
658
|
+
&& task.planHash === null
|
|
659
|
+
&& task.planObjective === null
|
|
660
|
+
&& task.resultHash === null
|
|
661
|
+
&& task.evidenceHash === null
|
|
662
|
+
&& task.knowledgeImpact === null
|
|
663
|
+
&& task.knowledgeImpactReason === null
|
|
664
|
+
&& task.knowledgeTargets.length === 0
|
|
665
|
+
&& task.knowledgeMapRevision === null
|
|
666
|
+
&& task.knowledgeMapHash === null
|
|
667
|
+
&& task.graphUse === null
|
|
668
|
+
&& task.steps.length === 0
|
|
669
|
+
&& task.authorizations.length === 0
|
|
670
|
+
&& task.knowledgeRebinds.length === 0
|
|
671
|
+
&& task.review.status === 'pending'
|
|
672
|
+
&& task.review.reviewer === ''
|
|
673
|
+
&& task.review.recordedAt === null
|
|
674
|
+
&& task.review.headCommit === null
|
|
675
|
+
&& task.review.briefHash === null
|
|
676
|
+
&& task.review.planHash === null
|
|
677
|
+
&& task.review.evidenceHash === null
|
|
678
|
+
&& task.review.summary === ''
|
|
679
|
+
&& task.review.findings.length === 0
|
|
680
|
+
&& task.blockReason === null
|
|
681
|
+
&& task.mergedCommit === null
|
|
682
|
+
&& task.systemCommits.length === 0;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
export function assertMilestoneScopeChangeStatusAllowed(milestone: MilestoneState): void {
|
|
686
|
+
if (!SCOPE_CHANGE_ALLOWED_STATUSES.has(milestone.status)) {
|
|
687
|
+
throw new WorkflowError('TRANSITION_BLOCKED', `Milestone ${milestone.id} cannot change scope while ${milestone.status}.`, {
|
|
688
|
+
milestoneId: milestone.id,
|
|
689
|
+
status: milestone.status,
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
export function prepareMilestoneScopeChangeCandidate(options: {
|
|
695
|
+
milestone: MilestoneState;
|
|
696
|
+
plan: MilestonePlanInput;
|
|
697
|
+
readTask: ReadTask;
|
|
698
|
+
actor: string;
|
|
699
|
+
autonomyContractEventHash?: string;
|
|
700
|
+
auditedRemediationEvidence?: AuditedRemediationEvidence;
|
|
701
|
+
}): PreparedMilestoneScopeChange {
|
|
702
|
+
const { milestone, plan, readTask, actor } = options;
|
|
703
|
+
const trimmedActor = actor.trim();
|
|
704
|
+
if (!trimmedActor) {
|
|
705
|
+
throw new WorkflowError('INVALID_ARGUMENT', 'Milestone scope change requires an explicit actor.');
|
|
706
|
+
}
|
|
707
|
+
if (
|
|
708
|
+
trimmedActor.startsWith('agent:')
|
|
709
|
+
&& !options.autonomyContractEventHash
|
|
710
|
+
&& !options.auditedRemediationEvidence
|
|
711
|
+
) {
|
|
712
|
+
throw new WorkflowError('TRANSITION_BLOCKED', 'Milestone scope change is human-only and rejects delegated agent actors.', {
|
|
713
|
+
actor: trimmedActor,
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
if (options.autonomyContractEventHash) {
|
|
717
|
+
assertHash64(options.autonomyContractEventHash, 'Milestone autonomy contract event hash');
|
|
718
|
+
}
|
|
719
|
+
if (options.autonomyContractEventHash && options.auditedRemediationEvidence) {
|
|
720
|
+
throw new WorkflowError('INVALID_ARGUMENT', 'Milestone scope change cannot combine autonomy and audited-remediation authority.');
|
|
721
|
+
}
|
|
722
|
+
if (options.auditedRemediationEvidence && !trimmedActor.startsWith('agent:')) {
|
|
723
|
+
throw new WorkflowError('INVALID_ARGUMENT', 'Audited remediation requires an agent actor.');
|
|
724
|
+
}
|
|
725
|
+
const authorizationMode = options.auditedRemediationEvidence
|
|
726
|
+
? 'audited-remediation' as const
|
|
727
|
+
: options.autonomyContractEventHash
|
|
728
|
+
? 'milestone-autonomy' as const
|
|
729
|
+
: 'human' as const;
|
|
730
|
+
const autonomyContractEventHash = options.autonomyContractEventHash ?? null;
|
|
731
|
+
const remediationEvidence = options.auditedRemediationEvidence ?? null;
|
|
732
|
+
const normalizedPlan = normalizeMilestonePlan(plan);
|
|
733
|
+
const before = milestoneScopeSnapshotFromState(milestone);
|
|
734
|
+
const after = milestoneScopeSnapshotFromPlan(normalizedPlan);
|
|
735
|
+
if (canonicalJsonStringify(before) === canonicalJsonStringify(after)) {
|
|
736
|
+
throw new WorkflowError('TRANSITION_BLOCKED', 'Milestone scope change is a no-op.', {
|
|
737
|
+
milestoneId: milestone.id,
|
|
738
|
+
revision: milestone.revision,
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
const taskIds = normalizedPlan.taskIds;
|
|
742
|
+
if (new Set(taskIds).size !== taskIds.length) {
|
|
743
|
+
throw new WorkflowError('INVALID_ARGUMENT', 'Milestone memberships must have unique Task IDs.');
|
|
744
|
+
}
|
|
745
|
+
const repairableCancelledRequired = new Set(
|
|
746
|
+
milestone.memberships
|
|
747
|
+
.filter((membership) => membership.disposition === 'required')
|
|
748
|
+
.map((membership) => membership.taskId)
|
|
749
|
+
.filter((taskId) => readTask(taskId).status === 'cancelled'),
|
|
750
|
+
);
|
|
751
|
+
for (const membership of normalizedPlan.memberships) {
|
|
752
|
+
const task = readTask(membership.taskId);
|
|
753
|
+
if (task.milestoneId !== milestone.id) {
|
|
754
|
+
throw new WorkflowError('TRANSITION_BLOCKED', `Task ${task.id} is not linked to Milestone ${milestone.id}.`);
|
|
755
|
+
}
|
|
756
|
+
if (repairableCancelledRequired.has(task.id) && membership.disposition !== 'cancelled') {
|
|
757
|
+
throw new WorkflowError(
|
|
758
|
+
'TRANSITION_BLOCKED',
|
|
759
|
+
`Milestone scope change must repair cancelled required Task ${task.id} by marking its membership cancelled.`,
|
|
760
|
+
{
|
|
761
|
+
milestoneId: milestone.id,
|
|
762
|
+
taskId: task.id,
|
|
763
|
+
currentDisposition: 'required',
|
|
764
|
+
candidateDisposition: membership.disposition,
|
|
765
|
+
},
|
|
766
|
+
);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
const nextIds = new Set(taskIds);
|
|
770
|
+
for (const existing of milestone.memberships) {
|
|
771
|
+
if (nextIds.has(existing.taskId)) continue;
|
|
772
|
+
const task = readTask(existing.taskId);
|
|
773
|
+
if (taskHasStarted(task)) {
|
|
774
|
+
throw new WorkflowError('TRANSITION_BLOCKED', `Started Task ${task.id} cannot be removed from a Milestone.`);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
const authorizations = milestone.authorizations.map((authorization) =>
|
|
778
|
+
authorization.kind === 'execution' && authorization.decision === 'approved'
|
|
779
|
+
? { ...authorization, decision: 'superseded' as const }
|
|
780
|
+
: authorization,
|
|
781
|
+
);
|
|
782
|
+
const candidatePlanHash = sha256Hex(renderMilestonePlanArtifact(normalizedPlan));
|
|
783
|
+
const candidateMembershipRevision = milestone.membershipRevision + 1;
|
|
784
|
+
const confirmationCodeBindingHash = computeMilestoneScopeChangeBindingHash({
|
|
785
|
+
milestoneId: milestone.id,
|
|
786
|
+
expectedRevision: milestone.revision,
|
|
787
|
+
previousPlanHash: milestone.planHash,
|
|
788
|
+
previousMembershipRevision: milestone.membershipRevision,
|
|
789
|
+
candidatePlanHash,
|
|
790
|
+
candidateMembershipRevision,
|
|
791
|
+
actor: trimmedActor,
|
|
792
|
+
after,
|
|
793
|
+
authorizationMode,
|
|
794
|
+
autonomyContractEventHash,
|
|
795
|
+
remediationEvidence,
|
|
796
|
+
});
|
|
797
|
+
return {
|
|
798
|
+
milestoneId: milestone.id,
|
|
799
|
+
expectedRevision: milestone.revision,
|
|
800
|
+
actor: trimmedActor,
|
|
801
|
+
normalizedPlan,
|
|
802
|
+
authorizations,
|
|
803
|
+
candidatePlanHash,
|
|
804
|
+
candidateMembershipRevision,
|
|
805
|
+
after,
|
|
806
|
+
confirmationCodeBindingHash,
|
|
807
|
+
confirmationCode: milestoneScopeChangeConfirmationCode(confirmationCodeBindingHash),
|
|
808
|
+
authorizationMode,
|
|
809
|
+
autonomyContractEventHash,
|
|
810
|
+
remediationEvidence,
|
|
811
|
+
resultingStatus: authorizationMode === 'audited-remediation'
|
|
812
|
+
? 'active'
|
|
813
|
+
: 'awaiting_execution_authorization',
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
export function applyMilestoneScopeChangeTransaction(options: {
|
|
818
|
+
store: FileStateStore;
|
|
819
|
+
milestone: MilestoneState;
|
|
820
|
+
expectedRevision: number;
|
|
821
|
+
prepared: PreparedMilestoneScopeChange;
|
|
822
|
+
now: Date;
|
|
823
|
+
readTask: ReadTask;
|
|
824
|
+
}): MilestoneState {
|
|
825
|
+
const { store, milestone, expectedRevision, prepared, now, readTask } = options;
|
|
826
|
+
const milestoneRoot = store.milestoneRoot(milestone.projectId, milestone.id);
|
|
827
|
+
const alpha6MilestoneRoot = resolveAlpha6MilestoneRoot(store, milestone.projectId, milestone.id);
|
|
828
|
+
const planText = renderMilestonePlanArtifact(prepared.normalizedPlan);
|
|
829
|
+
const stagedPlan = store.stageArtifact(milestoneRoot, 'plan.json', planText);
|
|
830
|
+
if (stagedPlan.hash !== prepared.candidatePlanHash) {
|
|
831
|
+
throw new WorkflowError('TRANSITION_BLOCKED', 'Milestone scope change candidate drifted before apply.', {
|
|
832
|
+
expectedPlanHash: prepared.candidatePlanHash,
|
|
833
|
+
actualPlanHash: stagedPlan.hash,
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
const currentEvents = readMilestoneScopeChangeEvents(store, milestone.projectId, milestone.id);
|
|
837
|
+
const preparedSidecar = prepareSidecarAppend<MilestoneScopeChangeEvent>(currentEvents, createMilestoneScopeChangeEvent({
|
|
838
|
+
milestone,
|
|
839
|
+
normalizedPlan: prepared.normalizedPlan,
|
|
840
|
+
actor: prepared.actor,
|
|
841
|
+
now,
|
|
842
|
+
previousEventHash: currentEvents.at(-1)?.eventHash ?? null,
|
|
843
|
+
candidatePlanHash: stagedPlan.hash,
|
|
844
|
+
candidateMembershipRevision: prepared.candidateMembershipRevision,
|
|
845
|
+
confirmationCodeBindingHash: prepared.confirmationCodeBindingHash,
|
|
846
|
+
authorizationMode: prepared.authorizationMode,
|
|
847
|
+
autonomyContractEventHash: prepared.autonomyContractEventHash,
|
|
848
|
+
remediationEvidence: prepared.remediationEvidence,
|
|
849
|
+
}), { validateEvent: (candidate) => validateMilestoneScopeChangeEvent(candidate, milestone.id) });
|
|
850
|
+
if (preparedSidecar.event.actor !== prepared.actor) {
|
|
851
|
+
throw new WorkflowError('STATE_CORRUPT', 'Prepared Milestone scope-change actor drifted before journal apply.', {
|
|
852
|
+
expectedActor: prepared.actor,
|
|
853
|
+
actualActor: preparedSidecar.event.actor,
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
if (preparedSidecar.event.confirmationCodeBindingHash !== prepared.confirmationCodeBindingHash) {
|
|
857
|
+
throw new WorkflowError('STATE_CORRUPT', 'Prepared Milestone scope-change binding drifted before journal apply.', {
|
|
858
|
+
expectedBindingHash: prepared.confirmationCodeBindingHash,
|
|
859
|
+
actualBindingHash: preparedSidecar.event.confirmationCodeBindingHash,
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
const stagedSidecar = store.stageArtifact(milestoneRoot, SCOPE_CHANGE_SIDECAR, preparedSidecar.content);
|
|
863
|
+
const candidateState: MilestoneState = {
|
|
864
|
+
...milestone,
|
|
865
|
+
revision: milestone.revision + 1,
|
|
866
|
+
updatedAt: now.toISOString(),
|
|
867
|
+
status: prepared.resultingStatus,
|
|
868
|
+
outcome: prepared.normalizedPlan.outcome,
|
|
869
|
+
successSignal: prepared.normalizedPlan.successSignal,
|
|
870
|
+
acceptance: prepared.normalizedPlan.acceptance,
|
|
871
|
+
taskIds: prepared.normalizedPlan.taskIds,
|
|
872
|
+
memberships: prepared.normalizedPlan.memberships,
|
|
873
|
+
checks: prepared.normalizedPlan.checks,
|
|
874
|
+
membershipRevision: prepared.candidateMembershipRevision,
|
|
875
|
+
authorizations: prepared.authorizations,
|
|
876
|
+
planHash: stagedPlan.hash,
|
|
877
|
+
resultHash: null,
|
|
878
|
+
evidenceHash: null,
|
|
879
|
+
acceptedHead: null,
|
|
880
|
+
cancellationReason: null,
|
|
881
|
+
};
|
|
882
|
+
assertMilestoneMembershipIntegrity(candidateState, readTask);
|
|
883
|
+
const stagedState = store.stageArtifact(
|
|
884
|
+
milestoneRoot,
|
|
885
|
+
'state.json',
|
|
886
|
+
`${JSON.stringify(candidateState, null, 2)}\n`,
|
|
887
|
+
);
|
|
888
|
+
const transactionId = `tx-${createEntityId('MS', now.getTime())}`;
|
|
889
|
+
const journal: MilestoneTransactionJournal = {
|
|
890
|
+
transactionId,
|
|
891
|
+
milestoneId: milestone.id,
|
|
892
|
+
expectedMilestoneRevision: milestone.revision,
|
|
893
|
+
actor: prepared.actor,
|
|
894
|
+
confirmationCodeBindingHash: prepared.confirmationCodeBindingHash,
|
|
895
|
+
phase: 'prepared',
|
|
896
|
+
targetFiles: [
|
|
897
|
+
{
|
|
898
|
+
path: 'state.json',
|
|
899
|
+
preimage: captureJournalTargetSnapshot(alpha6MilestoneRoot, 'state.json'),
|
|
900
|
+
staged: createJournalSnapshotFromContent(alpha6MilestoneRoot, readFileSync(stagedState.stagedFile), { mode: 'inline' }),
|
|
901
|
+
},
|
|
902
|
+
{
|
|
903
|
+
path: 'plan.json',
|
|
904
|
+
preimage: captureJournalTargetSnapshot(alpha6MilestoneRoot, 'plan.json'),
|
|
905
|
+
staged: createJournalSnapshotFromContent(alpha6MilestoneRoot, readFileSync(stagedPlan.stagedFile), { mode: 'inline' }),
|
|
906
|
+
},
|
|
907
|
+
{
|
|
908
|
+
path: SCOPE_CHANGE_SIDECAR,
|
|
909
|
+
preimage: captureJournalTargetSnapshot(alpha6MilestoneRoot, SCOPE_CHANGE_SIDECAR),
|
|
910
|
+
staged: createJournalSnapshotFromContent(alpha6MilestoneRoot, readFileSync(stagedSidecar.stagedFile), { mode: 'inline' }),
|
|
911
|
+
},
|
|
912
|
+
],
|
|
913
|
+
preparedAt: now.toISOString(),
|
|
914
|
+
updatedAt: now.toISOString(),
|
|
915
|
+
};
|
|
916
|
+
if (journal.actor !== preparedSidecar.event.actor) {
|
|
917
|
+
throw new WorkflowError('STATE_CORRUPT', 'Milestone transaction journal actor does not match latest staged scope-change event.', {
|
|
918
|
+
journalActor: journal.actor,
|
|
919
|
+
eventActor: preparedSidecar.event.actor,
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
if (journal.confirmationCodeBindingHash !== preparedSidecar.event.confirmationCodeBindingHash) {
|
|
923
|
+
throw new WorkflowError('STATE_CORRUPT', 'Milestone transaction journal binding does not match latest staged scope-change event.', {
|
|
924
|
+
journalBindingHash: journal.confirmationCodeBindingHash,
|
|
925
|
+
eventBindingHash: preparedSidecar.event.confirmationCodeBindingHash,
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
writeMilestoneTransactionJournal(alpha6MilestoneRoot, journal);
|
|
929
|
+
try {
|
|
930
|
+
updateMilestoneTransactionJournalPhase(alpha6MilestoneRoot, transactionId, 'applying', now.toISOString(), {
|
|
931
|
+
expectedMilestoneId: milestone.id,
|
|
932
|
+
});
|
|
933
|
+
store.publishArtifact(stagedPlan);
|
|
934
|
+
store.publishArtifact({ ...stagedSidecar, hash: preparedSidecar.contentHash });
|
|
935
|
+
store.publishArtifact(stagedState);
|
|
936
|
+
} catch (error) {
|
|
937
|
+
rollbackMilestoneTransaction(store, milestone.projectId, milestone.id, journal, readTask);
|
|
938
|
+
throw error;
|
|
939
|
+
}
|
|
940
|
+
return finalizeCompletedMilestoneTransaction(store, milestone.projectId, milestone.id, journal, readTask, {
|
|
941
|
+
expectedRevisionAfterApply: expectedRevision + 1,
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
export function applyInitialMilestonePlanTransaction(options: {
|
|
946
|
+
store: FileStateStore;
|
|
947
|
+
milestone: MilestoneState;
|
|
948
|
+
expectedRevision: number;
|
|
949
|
+
normalizedPlan: NormalizedMilestonePlan;
|
|
950
|
+
now: Date;
|
|
951
|
+
readTask: ReadTask;
|
|
952
|
+
listTasks?: ListTasks;
|
|
953
|
+
}): MilestoneState {
|
|
954
|
+
const { store, milestone, expectedRevision, normalizedPlan, now, readTask, listTasks } = options;
|
|
955
|
+
if (
|
|
956
|
+
milestone.status !== 'planning'
|
|
957
|
+
|| milestone.planHash !== null
|
|
958
|
+
|| milestone.membershipRevision !== 0
|
|
959
|
+
|| milestone.taskIds.length > 0
|
|
960
|
+
|| milestone.memberships.length > 0
|
|
961
|
+
) {
|
|
962
|
+
throw new WorkflowError('TRANSITION_BLOCKED', `Milestone ${milestone.id} is not in the initial Plan posture.`);
|
|
963
|
+
}
|
|
964
|
+
const milestoneRoot = store.milestoneRoot(milestone.projectId, milestone.id);
|
|
965
|
+
const alpha6MilestoneRoot = resolveAlpha6MilestoneRoot(store, milestone.projectId, milestone.id);
|
|
966
|
+
const planText = renderMilestonePlanArtifact(normalizedPlan);
|
|
967
|
+
const stagedPlan = store.stageArtifact(milestoneRoot, 'plan.json', planText);
|
|
968
|
+
const authorizations = milestone.authorizations.map((authorization) =>
|
|
969
|
+
authorization.kind === 'execution' && authorization.decision === 'approved'
|
|
970
|
+
? { ...authorization, decision: 'superseded' as const }
|
|
971
|
+
: authorization,
|
|
972
|
+
);
|
|
973
|
+
const candidateState: MilestoneState = {
|
|
974
|
+
...milestone,
|
|
975
|
+
revision: milestone.revision + 1,
|
|
976
|
+
updatedAt: now.toISOString(),
|
|
977
|
+
status: 'awaiting_execution_authorization',
|
|
978
|
+
outcome: normalizedPlan.outcome,
|
|
979
|
+
successSignal: normalizedPlan.successSignal,
|
|
980
|
+
acceptance: normalizedPlan.acceptance,
|
|
981
|
+
taskIds: normalizedPlan.taskIds,
|
|
982
|
+
memberships: normalizedPlan.memberships,
|
|
983
|
+
checks: normalizedPlan.checks,
|
|
984
|
+
membershipRevision: 1,
|
|
985
|
+
authorizations,
|
|
986
|
+
planHash: stagedPlan.hash,
|
|
987
|
+
resultHash: null,
|
|
988
|
+
evidenceHash: null,
|
|
989
|
+
acceptedHead: null,
|
|
990
|
+
cancellationReason: null,
|
|
991
|
+
};
|
|
992
|
+
assertMilestoneMembershipIntegrity(candidateState, readTask, {}, listTasks);
|
|
993
|
+
const stagedState = store.stageArtifact(
|
|
994
|
+
milestoneRoot,
|
|
995
|
+
'state.json',
|
|
996
|
+
`${JSON.stringify(candidateState, null, 2)}\n`,
|
|
997
|
+
);
|
|
998
|
+
const transactionId = `tx-${createEntityId('MS', now.getTime())}`;
|
|
999
|
+
const actor = 'workflow:milestone-plan-set';
|
|
1000
|
+
const confirmationCodeBindingHash = sha256Hex(canonicalJsonStringify({
|
|
1001
|
+
kind: 'milestone-initial-plan-v1',
|
|
1002
|
+
milestoneId: milestone.id,
|
|
1003
|
+
expectedRevision,
|
|
1004
|
+
planHash: stagedPlan.hash,
|
|
1005
|
+
membershipRevision: candidateState.membershipRevision,
|
|
1006
|
+
}));
|
|
1007
|
+
const journal: MilestoneTransactionJournal = {
|
|
1008
|
+
transactionId,
|
|
1009
|
+
milestoneId: milestone.id,
|
|
1010
|
+
expectedMilestoneRevision: milestone.revision,
|
|
1011
|
+
actor,
|
|
1012
|
+
confirmationCodeBindingHash,
|
|
1013
|
+
phase: 'prepared',
|
|
1014
|
+
targetFiles: [
|
|
1015
|
+
{
|
|
1016
|
+
path: 'state.json',
|
|
1017
|
+
preimage: captureJournalTargetSnapshot(alpha6MilestoneRoot, 'state.json'),
|
|
1018
|
+
staged: createJournalSnapshotFromContent(alpha6MilestoneRoot, readFileSync(stagedState.stagedFile), { mode: 'inline' }),
|
|
1019
|
+
},
|
|
1020
|
+
{
|
|
1021
|
+
path: 'plan.json',
|
|
1022
|
+
preimage: captureJournalTargetSnapshot(alpha6MilestoneRoot, 'plan.json'),
|
|
1023
|
+
staged: createJournalSnapshotFromContent(alpha6MilestoneRoot, readFileSync(stagedPlan.stagedFile), { mode: 'inline' }),
|
|
1024
|
+
},
|
|
1025
|
+
],
|
|
1026
|
+
preparedAt: now.toISOString(),
|
|
1027
|
+
updatedAt: now.toISOString(),
|
|
1028
|
+
};
|
|
1029
|
+
writeMilestoneTransactionJournal(alpha6MilestoneRoot, journal);
|
|
1030
|
+
try {
|
|
1031
|
+
updateMilestoneTransactionJournalPhase(alpha6MilestoneRoot, transactionId, 'applying', now.toISOString(), {
|
|
1032
|
+
expectedMilestoneId: milestone.id,
|
|
1033
|
+
});
|
|
1034
|
+
store.publishArtifact(stagedPlan);
|
|
1035
|
+
store.publishArtifact(stagedState);
|
|
1036
|
+
} catch (error) {
|
|
1037
|
+
rollbackMilestoneTransaction(store, milestone.projectId, milestone.id, journal, readTask);
|
|
1038
|
+
throw error;
|
|
1039
|
+
}
|
|
1040
|
+
return finalizeCompletedMilestoneTransaction(store, milestone.projectId, milestone.id, journal, readTask, {
|
|
1041
|
+
expectedRevisionAfterApply: expectedRevision + 1,
|
|
1042
|
+
});
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
function recoverMilestoneScopeChangeTransactions(
|
|
1046
|
+
store: FileStateStore,
|
|
1047
|
+
projectId: string,
|
|
1048
|
+
milestoneId: string,
|
|
1049
|
+
readTask: ReadTask,
|
|
1050
|
+
): void {
|
|
1051
|
+
const alpha6MilestoneRoot = resolveAlpha6MilestoneRoot(store, projectId, milestoneId);
|
|
1052
|
+
const transactionDir = path.join(alpha6MilestoneRoot, '.transactions');
|
|
1053
|
+
if (!existsSync(transactionDir)) return;
|
|
1054
|
+
const journals = listMilestoneTransactionJournals(alpha6MilestoneRoot, { expectedMilestoneId: milestoneId });
|
|
1055
|
+
for (const journal of journals) {
|
|
1056
|
+
validateMilestoneTransactionTargets(journal);
|
|
1057
|
+
try {
|
|
1058
|
+
if (shouldCompleteMilestoneTransaction(store, projectId, milestoneId, journal, readTask)) {
|
|
1059
|
+
completeMilestoneTransaction(store, projectId, milestoneId, journal, readTask);
|
|
1060
|
+
continue;
|
|
1061
|
+
}
|
|
1062
|
+
} catch {
|
|
1063
|
+
rollbackMilestoneTransaction(store, projectId, milestoneId, journal, readTask);
|
|
1064
|
+
continue;
|
|
1065
|
+
}
|
|
1066
|
+
rollbackMilestoneTransaction(store, projectId, milestoneId, journal, readTask);
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
function assertMilestonePlanAgreement(store: FileStateStore, milestone: MilestoneState): void {
|
|
1071
|
+
if (milestone.planHash === null) return;
|
|
1072
|
+
const milestoneRoot = store.milestoneRoot(milestone.projectId, milestone.id);
|
|
1073
|
+
let currentPlanHash: string;
|
|
1074
|
+
try {
|
|
1075
|
+
currentPlanHash = store.hashArtifact(milestoneRoot, 'plan.json');
|
|
1076
|
+
} catch (error) {
|
|
1077
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestone.id} declares a planHash but plan.json is unavailable.`, {
|
|
1078
|
+
milestoneId: milestone.id,
|
|
1079
|
+
expectedPlanHash: milestone.planHash,
|
|
1080
|
+
cause: error instanceof Error ? error.message : String(error),
|
|
1081
|
+
});
|
|
1082
|
+
}
|
|
1083
|
+
if (currentPlanHash !== milestone.planHash) {
|
|
1084
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestone.id} planHash does not match plan.json.`, {
|
|
1085
|
+
expectedPlanHash: milestone.planHash,
|
|
1086
|
+
actualPlanHash: currentPlanHash,
|
|
1087
|
+
});
|
|
1088
|
+
}
|
|
1089
|
+
const plan = parseMilestonePlanArtifact(readFileSync(path.join(milestoneRoot, 'plan.json'), 'utf8'), milestone.id);
|
|
1090
|
+
const expectedScope = milestoneScopeSnapshotFromState(milestone);
|
|
1091
|
+
const actualScope = milestoneScopeSnapshotFromPlan(plan);
|
|
1092
|
+
if (canonicalJsonStringify(expectedScope) !== canonicalJsonStringify(actualScope)) {
|
|
1093
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestone.id} state.json and plan.json diverged.`, {
|
|
1094
|
+
milestoneId: milestone.id,
|
|
1095
|
+
stateScope: expectedScope,
|
|
1096
|
+
planScope: actualScope,
|
|
1097
|
+
});
|
|
1098
|
+
}
|
|
1099
|
+
const scopeEvents = readMilestoneScopeChangeEvents(store, milestone.projectId, milestone.id);
|
|
1100
|
+
if (scopeEvents.length === 0) return;
|
|
1101
|
+
const latest = scopeEvents.at(-1)!;
|
|
1102
|
+
if (
|
|
1103
|
+
latest.candidatePlanHash !== milestone.planHash
|
|
1104
|
+
|| latest.candidateMembershipRevision !== milestone.membershipRevision
|
|
1105
|
+
|| canonicalJsonStringify(latest.after) !== canonicalJsonStringify(expectedScope)
|
|
1106
|
+
) {
|
|
1107
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestone.id} scope-change sidecar diverged from current state.`, {
|
|
1108
|
+
milestoneId: milestone.id,
|
|
1109
|
+
latestEventId: latest.eventId,
|
|
1110
|
+
latestCandidatePlanHash: latest.candidatePlanHash,
|
|
1111
|
+
milestonePlanHash: milestone.planHash,
|
|
1112
|
+
latestCandidateMembershipRevision: latest.candidateMembershipRevision,
|
|
1113
|
+
milestoneMembershipRevision: milestone.membershipRevision,
|
|
1114
|
+
});
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
export function readMilestoneScopeChangeEvents(
|
|
1119
|
+
store: FileStateStore,
|
|
1120
|
+
projectId: string,
|
|
1121
|
+
milestoneId: string,
|
|
1122
|
+
): MilestoneScopeChangeEvent[] {
|
|
1123
|
+
const sidecarPath = path.join(resolveAlpha6MilestoneRoot(store, projectId, milestoneId), SCOPE_CHANGE_SIDECAR);
|
|
1124
|
+
assertNoSymlinkPathComponents(sidecarPath, { label: 'Milestone scope-change sidecar path', requireLeaf: true });
|
|
1125
|
+
if (!existsSync(sidecarPath)) return [];
|
|
1126
|
+
return parseMilestoneScopeChangeEvents(readFileSync(sidecarPath, 'utf8'), milestoneId);
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
function validateMilestoneScopeChangeEvent(event: MilestoneScopeChangeEvent, expectedMilestoneId: string): void {
|
|
1130
|
+
if (event.milestoneId !== expectedMilestoneId) {
|
|
1131
|
+
throw new WorkflowError('STATE_CORRUPT', 'Milestone scope-change sidecar milestone mismatch.', {
|
|
1132
|
+
expectedMilestoneId,
|
|
1133
|
+
actualMilestoneId: event.milestoneId,
|
|
1134
|
+
eventId: event.eventId,
|
|
1135
|
+
});
|
|
1136
|
+
}
|
|
1137
|
+
if (!Number.isInteger(event.previousMilestoneRevision) || event.previousMilestoneRevision < 1) {
|
|
1138
|
+
throw new WorkflowError('STATE_CORRUPT', 'Milestone scope-change previousMilestoneRevision is invalid.', {
|
|
1139
|
+
eventId: event.eventId,
|
|
1140
|
+
previousMilestoneRevision: event.previousMilestoneRevision,
|
|
1141
|
+
});
|
|
1142
|
+
}
|
|
1143
|
+
if (!Number.isInteger(event.previousMembershipRevision) || event.previousMembershipRevision < 0) {
|
|
1144
|
+
throw new WorkflowError('STATE_CORRUPT', 'Milestone scope-change previousMembershipRevision is invalid.', {
|
|
1145
|
+
eventId: event.eventId,
|
|
1146
|
+
previousMembershipRevision: event.previousMembershipRevision,
|
|
1147
|
+
});
|
|
1148
|
+
}
|
|
1149
|
+
if (!Number.isInteger(event.candidateMembershipRevision) || event.candidateMembershipRevision < 1) {
|
|
1150
|
+
throw new WorkflowError('STATE_CORRUPT', 'Milestone scope-change candidateMembershipRevision is invalid.', {
|
|
1151
|
+
eventId: event.eventId,
|
|
1152
|
+
candidateMembershipRevision: event.candidateMembershipRevision,
|
|
1153
|
+
});
|
|
1154
|
+
}
|
|
1155
|
+
if (event.candidateMembershipRevision !== event.previousMembershipRevision + 1) {
|
|
1156
|
+
throw new WorkflowError('STATE_CORRUPT', 'Milestone scope-change membershipRevision must advance by exactly one.', {
|
|
1157
|
+
eventId: event.eventId,
|
|
1158
|
+
previousMembershipRevision: event.previousMembershipRevision,
|
|
1159
|
+
candidateMembershipRevision: event.candidateMembershipRevision,
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1162
|
+
if (!event.actor.trim()) {
|
|
1163
|
+
throw new WorkflowError('STATE_CORRUPT', 'Milestone scope-change actor is required.', { eventId: event.eventId });
|
|
1164
|
+
}
|
|
1165
|
+
if (
|
|
1166
|
+
event.actor.startsWith('agent:')
|
|
1167
|
+
&& event.authorizationMode !== 'milestone-autonomy'
|
|
1168
|
+
&& event.authorizationMode !== 'audited-remediation'
|
|
1169
|
+
) {
|
|
1170
|
+
throw new WorkflowError('STATE_CORRUPT', 'Milestone scope-change actor must be human-only.', {
|
|
1171
|
+
eventId: event.eventId,
|
|
1172
|
+
actor: event.actor,
|
|
1173
|
+
});
|
|
1174
|
+
}
|
|
1175
|
+
if (event.authorizationMode === 'milestone-autonomy') {
|
|
1176
|
+
if (!event.actor.startsWith('agent:') || !event.autonomyContractEventHash) {
|
|
1177
|
+
throw new WorkflowError('STATE_CORRUPT', 'Autonomous Milestone scope change requires an agent actor and contract evidence.', {
|
|
1178
|
+
eventId: event.eventId,
|
|
1179
|
+
});
|
|
1180
|
+
}
|
|
1181
|
+
assertHash64(event.autonomyContractEventHash, 'Milestone scope-change autonomyContractEventHash');
|
|
1182
|
+
} else if (event.autonomyContractEventHash !== undefined && event.autonomyContractEventHash !== null) {
|
|
1183
|
+
throw new WorkflowError('STATE_CORRUPT', 'Human Milestone scope change cannot carry autonomy contract evidence.');
|
|
1184
|
+
}
|
|
1185
|
+
if (event.authorizationMode === 'audited-remediation') {
|
|
1186
|
+
if (event.actor !== `agent:milestone-remediation:${event.milestoneId}` || !event.remediationEvidence) {
|
|
1187
|
+
throw new WorkflowError('STATE_CORRUPT', 'Audited remediation requires an agent actor and bound remediation evidence.', {
|
|
1188
|
+
eventId: event.eventId,
|
|
1189
|
+
});
|
|
1190
|
+
}
|
|
1191
|
+
validateAuditedRemediationEvidence(event.remediationEvidence, event.eventId);
|
|
1192
|
+
validateAuditedRemediationTopologyEvent(event);
|
|
1193
|
+
} else if (event.remediationEvidence !== undefined && event.remediationEvidence !== null) {
|
|
1194
|
+
throw new WorkflowError('STATE_CORRUPT', 'Non-remediation scope change cannot carry remediation evidence.', {
|
|
1195
|
+
eventId: event.eventId,
|
|
1196
|
+
});
|
|
1197
|
+
}
|
|
1198
|
+
if (!event.confirmationCodeBindingHash.trim()) {
|
|
1199
|
+
throw new WorkflowError('STATE_CORRUPT', 'Milestone scope-change confirmationCodeBindingHash is required.', {
|
|
1200
|
+
eventId: event.eventId,
|
|
1201
|
+
});
|
|
1202
|
+
}
|
|
1203
|
+
assertHash64(event.confirmationCodeBindingHash, 'Milestone scope-change confirmationCodeBindingHash');
|
|
1204
|
+
const expectedBindingHash = computeMilestoneScopeChangeBindingHash({
|
|
1205
|
+
milestoneId: event.milestoneId,
|
|
1206
|
+
expectedRevision: event.previousMilestoneRevision,
|
|
1207
|
+
previousPlanHash: event.previousPlanHash,
|
|
1208
|
+
previousMembershipRevision: event.previousMembershipRevision,
|
|
1209
|
+
candidatePlanHash: event.candidatePlanHash,
|
|
1210
|
+
candidateMembershipRevision: event.candidateMembershipRevision,
|
|
1211
|
+
actor: event.actor,
|
|
1212
|
+
after: event.after,
|
|
1213
|
+
...(event.authorizationMode === undefined ? {} : { authorizationMode: event.authorizationMode }),
|
|
1214
|
+
...(event.autonomyContractEventHash === undefined
|
|
1215
|
+
? {}
|
|
1216
|
+
: { autonomyContractEventHash: event.autonomyContractEventHash }),
|
|
1217
|
+
...(event.remediationEvidence === undefined
|
|
1218
|
+
? {}
|
|
1219
|
+
: { remediationEvidence: event.remediationEvidence }),
|
|
1220
|
+
});
|
|
1221
|
+
if (event.confirmationCodeBindingHash !== expectedBindingHash) {
|
|
1222
|
+
throw new WorkflowError('STATE_CORRUPT', 'Milestone scope-change confirmationCodeBindingHash is inconsistent.', {
|
|
1223
|
+
eventId: event.eventId,
|
|
1224
|
+
expectedBindingHash,
|
|
1225
|
+
actualBindingHash: event.confirmationCodeBindingHash,
|
|
1226
|
+
});
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
function validateMilestoneTransactionTargets(journal: MilestoneTransactionJournal): void {
|
|
1231
|
+
const paths = journal.targetFiles.map((target) => target.path);
|
|
1232
|
+
const unique = new Set(paths);
|
|
1233
|
+
const expectedTargets = unique.has(SCOPE_CHANGE_SIDECAR) ? MILESTONE_TARGETS : INITIAL_PLAN_TARGETS;
|
|
1234
|
+
if (unique.size !== expectedTargets.length || paths.length !== expectedTargets.length) {
|
|
1235
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone transaction ${journal.transactionId} target set is invalid.`, {
|
|
1236
|
+
paths,
|
|
1237
|
+
});
|
|
1238
|
+
}
|
|
1239
|
+
for (const target of expectedTargets) {
|
|
1240
|
+
if (!unique.has(target)) {
|
|
1241
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone transaction ${journal.transactionId} is missing ${target}.`);
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
function shouldCompleteMilestoneTransaction(
|
|
1247
|
+
store: FileStateStore,
|
|
1248
|
+
projectId: string,
|
|
1249
|
+
milestoneId: string,
|
|
1250
|
+
journal: MilestoneTransactionJournal,
|
|
1251
|
+
readTask: ReadTask,
|
|
1252
|
+
): boolean {
|
|
1253
|
+
const staged = readStagedMilestoneTransactionBundle(store, projectId, milestoneId, journal);
|
|
1254
|
+
const currentStates = journal.targetFiles.map((target) => {
|
|
1255
|
+
const current = captureJournalTargetSnapshot(resolveAlpha6MilestoneRoot(store, projectId, milestoneId), target.path);
|
|
1256
|
+
const matchesPreimage = canonicalJsonStringify(current) === canonicalJsonStringify(target.preimage);
|
|
1257
|
+
const matchesStaged = canonicalJsonStringify(current) === canonicalJsonStringify(target.staged);
|
|
1258
|
+
return { target: target.path, matchesPreimage, matchesStaged };
|
|
1259
|
+
});
|
|
1260
|
+
if (currentStates.some((state) => !state.matchesPreimage && !state.matchesStaged)) return false;
|
|
1261
|
+
if (staged.state.id !== journal.milestoneId || staged.state.revision !== journal.expectedMilestoneRevision + 1) {
|
|
1262
|
+
return false;
|
|
1263
|
+
}
|
|
1264
|
+
assertMilestoneMembershipIntegrity(staged.state, readTask);
|
|
1265
|
+
return true;
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
function completeMilestoneTransaction(
|
|
1269
|
+
store: FileStateStore,
|
|
1270
|
+
projectId: string,
|
|
1271
|
+
milestoneId: string,
|
|
1272
|
+
journal: MilestoneTransactionJournal,
|
|
1273
|
+
readTask: ReadTask,
|
|
1274
|
+
): void {
|
|
1275
|
+
const alpha6MilestoneRoot = resolveAlpha6MilestoneRoot(store, projectId, milestoneId);
|
|
1276
|
+
try {
|
|
1277
|
+
updateMilestoneTransactionJournalPhase(alpha6MilestoneRoot, journal.transactionId, 'applying', new Date().toISOString(), {
|
|
1278
|
+
expectedMilestoneId: milestoneId,
|
|
1279
|
+
});
|
|
1280
|
+
for (const target of journal.targetFiles) {
|
|
1281
|
+
const content = readJournalSnapshotContent(alpha6MilestoneRoot, target.staged, {
|
|
1282
|
+
expectedMilestoneId: milestoneId,
|
|
1283
|
+
transactionId: journal.transactionId,
|
|
1284
|
+
});
|
|
1285
|
+
if (content === null) {
|
|
1286
|
+
rmSync(resolveMilestoneTransactionTargetPath(alpha6MilestoneRoot, target.path), { force: true });
|
|
1287
|
+
continue;
|
|
1288
|
+
}
|
|
1289
|
+
const staged = store.stageArtifact(
|
|
1290
|
+
store.milestoneRoot(projectId, milestoneId),
|
|
1291
|
+
target.path,
|
|
1292
|
+
content.toString('utf8'),
|
|
1293
|
+
);
|
|
1294
|
+
store.publishArtifact(staged);
|
|
1295
|
+
}
|
|
1296
|
+
} catch (error) {
|
|
1297
|
+
rollbackMilestoneTransaction(store, projectId, milestoneId, journal, readTask);
|
|
1298
|
+
throw error;
|
|
1299
|
+
}
|
|
1300
|
+
finalizeCompletedMilestoneTransaction(store, projectId, milestoneId, journal, readTask);
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
function rollbackMilestoneTransaction(
|
|
1304
|
+
store: FileStateStore,
|
|
1305
|
+
projectId: string,
|
|
1306
|
+
milestoneId: string,
|
|
1307
|
+
journal: MilestoneTransactionJournal,
|
|
1308
|
+
readTask: ReadTask,
|
|
1309
|
+
): void {
|
|
1310
|
+
const alpha6MilestoneRoot = resolveAlpha6MilestoneRoot(store, projectId, milestoneId);
|
|
1311
|
+
for (const target of journal.targetFiles) {
|
|
1312
|
+
const content = readJournalSnapshotContent(alpha6MilestoneRoot, target.preimage, {
|
|
1313
|
+
expectedMilestoneId: milestoneId,
|
|
1314
|
+
transactionId: journal.transactionId,
|
|
1315
|
+
});
|
|
1316
|
+
if (content === null) {
|
|
1317
|
+
rmSync(resolveMilestoneTransactionTargetPath(alpha6MilestoneRoot, target.path), { force: true });
|
|
1318
|
+
continue;
|
|
1319
|
+
}
|
|
1320
|
+
const staged = store.stageArtifact(
|
|
1321
|
+
store.milestoneRoot(projectId, milestoneId),
|
|
1322
|
+
target.path,
|
|
1323
|
+
content.toString('utf8'),
|
|
1324
|
+
);
|
|
1325
|
+
store.publishArtifact(staged);
|
|
1326
|
+
}
|
|
1327
|
+
try {
|
|
1328
|
+
const restored = store.readMilestone(projectId, milestoneId);
|
|
1329
|
+
if (restored.revision !== journal.expectedMilestoneRevision) {
|
|
1330
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestoneId} rollback restored an unexpected revision.`, {
|
|
1331
|
+
expectedRevision: journal.expectedMilestoneRevision,
|
|
1332
|
+
actualRevision: restored.revision,
|
|
1333
|
+
});
|
|
1334
|
+
}
|
|
1335
|
+
assertMilestonePlanAgreement(store, restored);
|
|
1336
|
+
assertMilestoneMembershipIntegrity(restored, readTask, { allowRepairableRequiredCancelled: true });
|
|
1337
|
+
} catch (error) {
|
|
1338
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestoneId} rollback validation failed.`, {
|
|
1339
|
+
transactionId: journal.transactionId,
|
|
1340
|
+
cause: error instanceof Error ? error.message : String(error),
|
|
1341
|
+
});
|
|
1342
|
+
}
|
|
1343
|
+
updateMilestoneTransactionJournalPhase(alpha6MilestoneRoot, journal.transactionId, 'rolled_back', new Date().toISOString(), {
|
|
1344
|
+
expectedMilestoneId: milestoneId,
|
|
1345
|
+
});
|
|
1346
|
+
removeMilestoneTransactionJournal(alpha6MilestoneRoot, journal.transactionId);
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
function finalizeCompletedMilestoneTransaction(
|
|
1350
|
+
store: FileStateStore,
|
|
1351
|
+
projectId: string,
|
|
1352
|
+
milestoneId: string,
|
|
1353
|
+
journal: MilestoneTransactionJournal,
|
|
1354
|
+
readTask: ReadTask,
|
|
1355
|
+
options: { expectedRevisionAfterApply?: number } = {},
|
|
1356
|
+
): MilestoneState {
|
|
1357
|
+
const alpha6MilestoneRoot = resolveAlpha6MilestoneRoot(store, projectId, milestoneId);
|
|
1358
|
+
let saved: MilestoneState;
|
|
1359
|
+
try {
|
|
1360
|
+
saved = store.readMilestone(projectId, milestoneId);
|
|
1361
|
+
if (options.expectedRevisionAfterApply !== undefined && saved.revision !== options.expectedRevisionAfterApply) {
|
|
1362
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestoneId} scope change wrote an unexpected revision.`, {
|
|
1363
|
+
expectedRevision: options.expectedRevisionAfterApply,
|
|
1364
|
+
actualRevision: saved.revision,
|
|
1365
|
+
});
|
|
1366
|
+
}
|
|
1367
|
+
assertMilestonePlanAgreement(store, saved);
|
|
1368
|
+
assertMilestoneMembershipIntegrity(saved, readTask);
|
|
1369
|
+
} catch (error) {
|
|
1370
|
+
rollbackMilestoneTransaction(store, projectId, milestoneId, journal, readTask);
|
|
1371
|
+
throw error;
|
|
1372
|
+
}
|
|
1373
|
+
updateMilestoneTransactionJournalPhase(alpha6MilestoneRoot, journal.transactionId, 'applied', new Date().toISOString(), {
|
|
1374
|
+
expectedMilestoneId: milestoneId,
|
|
1375
|
+
});
|
|
1376
|
+
removeMilestoneTransactionJournal(alpha6MilestoneRoot, journal.transactionId);
|
|
1377
|
+
return saved;
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
function readStagedMilestoneTransactionBundle(
|
|
1381
|
+
store: FileStateStore,
|
|
1382
|
+
projectId: string,
|
|
1383
|
+
milestoneId: string,
|
|
1384
|
+
journal: MilestoneTransactionJournal,
|
|
1385
|
+
): {
|
|
1386
|
+
state: MilestoneState;
|
|
1387
|
+
plan: PersistedMilestonePlan;
|
|
1388
|
+
events: MilestoneScopeChangeEvent[];
|
|
1389
|
+
} {
|
|
1390
|
+
const stateSnapshot = journal.targetFiles.find((target) => target.path === 'state.json')?.staged;
|
|
1391
|
+
const planSnapshot = journal.targetFiles.find((target) => target.path === 'plan.json')?.staged;
|
|
1392
|
+
const sidecarSnapshot = journal.targetFiles.find((target) => target.path === SCOPE_CHANGE_SIDECAR)?.staged;
|
|
1393
|
+
if (!stateSnapshot || !planSnapshot) {
|
|
1394
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone transaction ${journal.transactionId} is missing a staged bundle.`);
|
|
1395
|
+
}
|
|
1396
|
+
const alpha6MilestoneRoot = resolveAlpha6MilestoneRoot(store, projectId, milestoneId);
|
|
1397
|
+
const stateBuffer = readJournalSnapshotContent(alpha6MilestoneRoot, stateSnapshot, {
|
|
1398
|
+
expectedMilestoneId: milestoneId,
|
|
1399
|
+
transactionId: journal.transactionId,
|
|
1400
|
+
});
|
|
1401
|
+
const planBuffer = readJournalSnapshotContent(alpha6MilestoneRoot, planSnapshot, {
|
|
1402
|
+
expectedMilestoneId: milestoneId,
|
|
1403
|
+
transactionId: journal.transactionId,
|
|
1404
|
+
});
|
|
1405
|
+
const sidecarBuffer = sidecarSnapshot
|
|
1406
|
+
? readJournalSnapshotContent(alpha6MilestoneRoot, sidecarSnapshot, {
|
|
1407
|
+
expectedMilestoneId: milestoneId,
|
|
1408
|
+
transactionId: journal.transactionId,
|
|
1409
|
+
})
|
|
1410
|
+
: null;
|
|
1411
|
+
if (!stateBuffer || !planBuffer || (sidecarSnapshot && !sidecarBuffer)) {
|
|
1412
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone transaction ${journal.transactionId} has an incomplete staged bundle.`);
|
|
1413
|
+
}
|
|
1414
|
+
const state = parseStagedMilestoneStateArtifact(
|
|
1415
|
+
stateBuffer.toString('utf8'),
|
|
1416
|
+
projectId,
|
|
1417
|
+
journal.milestoneId,
|
|
1418
|
+
journal.transactionId,
|
|
1419
|
+
);
|
|
1420
|
+
const plan = parseMilestonePlanArtifact(planBuffer.toString('utf8'), journal.milestoneId);
|
|
1421
|
+
const events = sidecarBuffer
|
|
1422
|
+
? parseMilestoneScopeChangeEvents(sidecarBuffer.toString('utf8'), journal.milestoneId)
|
|
1423
|
+
: [];
|
|
1424
|
+
const latestEvent = events.at(-1) ?? null;
|
|
1425
|
+
if (
|
|
1426
|
+
canonicalJsonStringify(milestoneScopeSnapshotFromPlan(plan)) !== canonicalJsonStringify(milestoneScopeSnapshotFromState(state))
|
|
1427
|
+
|| (latestEvent !== null && (
|
|
1428
|
+
latestEvent.candidatePlanHash !== state.planHash
|
|
1429
|
+
|| latestEvent.candidateMembershipRevision !== state.membershipRevision
|
|
1430
|
+
|| canonicalJsonStringify(latestEvent.after) !== canonicalJsonStringify(milestoneScopeSnapshotFromState(state))
|
|
1431
|
+
|| latestEvent.actor !== journal.actor
|
|
1432
|
+
|| latestEvent.confirmationCodeBindingHash !== journal.confirmationCodeBindingHash
|
|
1433
|
+
))
|
|
1434
|
+
) {
|
|
1435
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone transaction ${journal.transactionId} staged bundle is inconsistent.`);
|
|
1436
|
+
}
|
|
1437
|
+
if (state.revision !== journal.expectedMilestoneRevision + 1) {
|
|
1438
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone transaction ${journal.transactionId} staged state revision is inconsistent.`, {
|
|
1439
|
+
expectedRevision: journal.expectedMilestoneRevision + 1,
|
|
1440
|
+
actualRevision: state.revision,
|
|
1441
|
+
});
|
|
1442
|
+
}
|
|
1443
|
+
if (latestEvent && latestEvent.previousMilestoneRevision !== journal.expectedMilestoneRevision) {
|
|
1444
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone transaction ${journal.transactionId} latest event revision does not match journal precondition.`, {
|
|
1445
|
+
expectedPreviousMilestoneRevision: journal.expectedMilestoneRevision,
|
|
1446
|
+
actualPreviousMilestoneRevision: latestEvent.previousMilestoneRevision,
|
|
1447
|
+
});
|
|
1448
|
+
}
|
|
1449
|
+
return { state, plan, events };
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
function parseMilestonePlanArtifact(content: string, milestoneId: string): PersistedMilestonePlan {
|
|
1453
|
+
let parsed: unknown;
|
|
1454
|
+
try {
|
|
1455
|
+
parsed = JSON.parse(content);
|
|
1456
|
+
} catch (error) {
|
|
1457
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestoneId} plan.json is not valid JSON.`, {
|
|
1458
|
+
cause: error instanceof Error ? error.message : String(error),
|
|
1459
|
+
});
|
|
1460
|
+
}
|
|
1461
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
1462
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestoneId} plan.json must be an object.`);
|
|
1463
|
+
}
|
|
1464
|
+
const record = parsed as Record<string, unknown>;
|
|
1465
|
+
const allowedKeys = new Set(['outcome', 'successSignal', 'acceptance', 'memberships', 'checks']);
|
|
1466
|
+
const extraKeys = Object.keys(record).filter((key) => !allowedKeys.has(key));
|
|
1467
|
+
if (extraKeys.length > 0) {
|
|
1468
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestoneId} plan.json contains unexpected fields.`, {
|
|
1469
|
+
extraKeys,
|
|
1470
|
+
});
|
|
1471
|
+
}
|
|
1472
|
+
if (typeof record.outcome !== 'string' || !record.outcome.trim()) {
|
|
1473
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestoneId} plan.json outcome must be a non-empty string.`);
|
|
1474
|
+
}
|
|
1475
|
+
if (typeof record.successSignal !== 'string' || !record.successSignal.trim()) {
|
|
1476
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestoneId} plan.json successSignal must be a non-empty string.`);
|
|
1477
|
+
}
|
|
1478
|
+
const acceptance = parseStringArray(record.acceptance, `Milestone ${milestoneId} plan.json acceptance`);
|
|
1479
|
+
const checks = parseStringArray(record.checks, `Milestone ${milestoneId} plan.json checks`);
|
|
1480
|
+
const memberships = parseMilestoneMembershipArray(record.memberships, milestoneId);
|
|
1481
|
+
const canonicalMembershipCount = memberships.filter((membership) => Array.isArray(membership.dependsOnTaskIds)).length;
|
|
1482
|
+
if (canonicalMembershipCount > 0 && canonicalMembershipCount !== memberships.length) {
|
|
1483
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestoneId} plan.json mixes canonical and legacy dependency memberships.`);
|
|
1484
|
+
}
|
|
1485
|
+
if (canonicalMembershipCount === memberships.length) {
|
|
1486
|
+
assertCanonicalMilestoneDependencyGraph(
|
|
1487
|
+
memberships as MilestonePlanMembershipInput[],
|
|
1488
|
+
'STATE_CORRUPT',
|
|
1489
|
+
`Milestone ${milestoneId} plan.json`,
|
|
1490
|
+
);
|
|
1491
|
+
}
|
|
1492
|
+
return {
|
|
1493
|
+
outcome: record.outcome.trim(),
|
|
1494
|
+
successSignal: record.successSignal.trim(),
|
|
1495
|
+
acceptance,
|
|
1496
|
+
memberships,
|
|
1497
|
+
checks,
|
|
1498
|
+
taskIds: memberships.map((membership) => membership.taskId),
|
|
1499
|
+
};
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
function parseMilestoneScopeChangeEvents(content: string, milestoneId: string): MilestoneScopeChangeEvent[] {
|
|
1503
|
+
if (content.length === 0) {
|
|
1504
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestoneId} scope-change sidecar must not be empty.`);
|
|
1505
|
+
}
|
|
1506
|
+
const rawLines = content.split('\n');
|
|
1507
|
+
if (rawLines.at(-1) === '') rawLines.pop();
|
|
1508
|
+
if (rawLines.some((line) => line.length === 0)) {
|
|
1509
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestoneId} scope-change sidecar contains a blank line.`);
|
|
1510
|
+
}
|
|
1511
|
+
const events: MilestoneScopeChangeEvent[] = [];
|
|
1512
|
+
const eventIds = new Set<string>();
|
|
1513
|
+
let previousEventHash: string | null = null;
|
|
1514
|
+
for (const [index, rawLine] of rawLines.entries()) {
|
|
1515
|
+
let parsed: unknown;
|
|
1516
|
+
try {
|
|
1517
|
+
parsed = JSON.parse(rawLine);
|
|
1518
|
+
} catch (error) {
|
|
1519
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestoneId} scope-change sidecar has invalid JSONL.`, {
|
|
1520
|
+
line: index + 1,
|
|
1521
|
+
cause: error instanceof Error ? error.message : String(error),
|
|
1522
|
+
});
|
|
1523
|
+
}
|
|
1524
|
+
const event = parseMilestoneScopeChangeEventRecord(parsed, milestoneId, index + 1);
|
|
1525
|
+
validateMilestoneScopeChangeEvent(event, milestoneId);
|
|
1526
|
+
if (eventIds.has(event.eventId)) {
|
|
1527
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestoneId} scope-change sidecar contains duplicate event id.`, {
|
|
1528
|
+
line: index + 1,
|
|
1529
|
+
eventId: event.eventId,
|
|
1530
|
+
});
|
|
1531
|
+
}
|
|
1532
|
+
eventIds.add(event.eventId);
|
|
1533
|
+
if (event.previousEventHash !== previousEventHash) {
|
|
1534
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestoneId} scope-change sidecar chain is broken.`, {
|
|
1535
|
+
line: index + 1,
|
|
1536
|
+
expectedPreviousEventHash: previousEventHash,
|
|
1537
|
+
actualPreviousEventHash: event.previousEventHash,
|
|
1538
|
+
});
|
|
1539
|
+
}
|
|
1540
|
+
const expectedEventHash = computeSidecarEventHash(event);
|
|
1541
|
+
if (event.eventHash !== expectedEventHash) {
|
|
1542
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestoneId} scope-change eventHash mismatch.`, {
|
|
1543
|
+
line: index + 1,
|
|
1544
|
+
expectedEventHash,
|
|
1545
|
+
actualEventHash: event.eventHash,
|
|
1546
|
+
});
|
|
1547
|
+
}
|
|
1548
|
+
previousEventHash = event.eventHash;
|
|
1549
|
+
const previous = events.at(-1);
|
|
1550
|
+
if (previous) {
|
|
1551
|
+
if (
|
|
1552
|
+
event.previousMilestoneRevision <= previous.previousMilestoneRevision
|
|
1553
|
+
|| event.previousPlanHash !== previous.candidatePlanHash
|
|
1554
|
+
|| event.previousMembershipRevision !== previous.candidateMembershipRevision
|
|
1555
|
+
|| canonicalJsonStringify(event.before) !== canonicalJsonStringify(previous.after)
|
|
1556
|
+
) {
|
|
1557
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestoneId} scope-change semantic continuity is broken.`, {
|
|
1558
|
+
line: index + 1,
|
|
1559
|
+
previousEventId: previous.eventId,
|
|
1560
|
+
eventId: event.eventId,
|
|
1561
|
+
});
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
events.push(event);
|
|
1565
|
+
}
|
|
1566
|
+
return events;
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
function createMilestoneScopeChangeEvent(options: {
|
|
1570
|
+
milestone: MilestoneState;
|
|
1571
|
+
normalizedPlan: NormalizedMilestonePlan;
|
|
1572
|
+
actor: string;
|
|
1573
|
+
now: Date;
|
|
1574
|
+
previousEventHash: string | null;
|
|
1575
|
+
candidatePlanHash: string;
|
|
1576
|
+
candidateMembershipRevision: number;
|
|
1577
|
+
confirmationCodeBindingHash: string;
|
|
1578
|
+
authorizationMode: 'human' | 'milestone-autonomy' | 'audited-remediation';
|
|
1579
|
+
autonomyContractEventHash: string | null;
|
|
1580
|
+
remediationEvidence: AuditedRemediationEvidence | null;
|
|
1581
|
+
}): Omit<MilestoneScopeChangeEvent, 'eventHash'> {
|
|
1582
|
+
const {
|
|
1583
|
+
milestone,
|
|
1584
|
+
normalizedPlan,
|
|
1585
|
+
actor,
|
|
1586
|
+
now,
|
|
1587
|
+
previousEventHash,
|
|
1588
|
+
candidatePlanHash,
|
|
1589
|
+
candidateMembershipRevision,
|
|
1590
|
+
confirmationCodeBindingHash,
|
|
1591
|
+
authorizationMode,
|
|
1592
|
+
autonomyContractEventHash,
|
|
1593
|
+
remediationEvidence,
|
|
1594
|
+
} = options;
|
|
1595
|
+
return {
|
|
1596
|
+
eventId: createEntityId('MS', now.getTime()),
|
|
1597
|
+
previousEventHash,
|
|
1598
|
+
recordedAt: now.toISOString(),
|
|
1599
|
+
milestoneId: milestone.id,
|
|
1600
|
+
previousMilestoneRevision: milestone.revision,
|
|
1601
|
+
previousPlanHash: milestone.planHash,
|
|
1602
|
+
previousMembershipRevision: milestone.membershipRevision,
|
|
1603
|
+
candidatePlanHash,
|
|
1604
|
+
candidateMembershipRevision,
|
|
1605
|
+
before: milestoneScopeSnapshotFromState(milestone),
|
|
1606
|
+
after: milestoneScopeSnapshotFromPlan(normalizedPlan),
|
|
1607
|
+
actor: actor.trim(),
|
|
1608
|
+
confirmationCodeBindingHash,
|
|
1609
|
+
...(authorizationMode === 'milestone-autonomy'
|
|
1610
|
+
? { authorizationMode, autonomyContractEventHash }
|
|
1611
|
+
: authorizationMode === 'audited-remediation'
|
|
1612
|
+
? { authorizationMode, remediationEvidence }
|
|
1613
|
+
: {}),
|
|
1614
|
+
};
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
function milestoneScopeSnapshotFromState(milestone: MilestoneState): MilestoneScopeSnapshot {
|
|
1618
|
+
return {
|
|
1619
|
+
outcome: milestone.outcome,
|
|
1620
|
+
successSignal: milestone.successSignal,
|
|
1621
|
+
acceptance: milestone.acceptance,
|
|
1622
|
+
checks: milestone.checks,
|
|
1623
|
+
taskIds: milestone.taskIds,
|
|
1624
|
+
memberships: milestone.memberships,
|
|
1625
|
+
};
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
function parseMilestoneScopeChangeEventRecord(
|
|
1629
|
+
parsed: unknown,
|
|
1630
|
+
milestoneId: string,
|
|
1631
|
+
line: number,
|
|
1632
|
+
): MilestoneScopeChangeEvent {
|
|
1633
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
1634
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestoneId} scope-change event must be an object.`, { line });
|
|
1635
|
+
}
|
|
1636
|
+
const record = parsed as Record<string, unknown>;
|
|
1637
|
+
const allowedKeys = new Set([
|
|
1638
|
+
'eventId',
|
|
1639
|
+
'recordedAt',
|
|
1640
|
+
'previousEventHash',
|
|
1641
|
+
'milestoneId',
|
|
1642
|
+
'previousMilestoneRevision',
|
|
1643
|
+
'previousPlanHash',
|
|
1644
|
+
'previousMembershipRevision',
|
|
1645
|
+
'candidatePlanHash',
|
|
1646
|
+
'candidateMembershipRevision',
|
|
1647
|
+
'before',
|
|
1648
|
+
'after',
|
|
1649
|
+
'actor',
|
|
1650
|
+
'confirmationCodeBindingHash',
|
|
1651
|
+
'authorizationMode',
|
|
1652
|
+
'autonomyContractEventHash',
|
|
1653
|
+
'remediationEvidence',
|
|
1654
|
+
'eventHash',
|
|
1655
|
+
]);
|
|
1656
|
+
const extraKeys = Object.keys(record).filter((key) => !allowedKeys.has(key));
|
|
1657
|
+
if (extraKeys.length > 0) {
|
|
1658
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestoneId} scope-change event contains unexpected fields.`, {
|
|
1659
|
+
line,
|
|
1660
|
+
extraKeys,
|
|
1661
|
+
});
|
|
1662
|
+
}
|
|
1663
|
+
const eventId = requireNonEmptyString(record.eventId, `Milestone ${milestoneId} scope-change eventId`, { line });
|
|
1664
|
+
const recordedAt = requireIsoTimestamp(record.recordedAt, `Milestone ${milestoneId} scope-change recordedAt`, { line });
|
|
1665
|
+
const previousEventHash = requireNullableHash64(record.previousEventHash, `Milestone ${milestoneId} scope-change previousEventHash`, { line });
|
|
1666
|
+
const actualMilestoneId = requireNonEmptyString(record.milestoneId, `Milestone ${milestoneId} scope-change milestoneId`, { line });
|
|
1667
|
+
const previousMilestoneRevision = requireInteger(record.previousMilestoneRevision, `Milestone ${milestoneId} scope-change previousMilestoneRevision`, { line });
|
|
1668
|
+
const previousPlanHash = requireNullableHash64(record.previousPlanHash, `Milestone ${milestoneId} scope-change previousPlanHash`, { line });
|
|
1669
|
+
const previousMembershipRevision = requireInteger(record.previousMembershipRevision, `Milestone ${milestoneId} scope-change previousMembershipRevision`, { line });
|
|
1670
|
+
const candidatePlanHash = requireHash64(record.candidatePlanHash, `Milestone ${milestoneId} scope-change candidatePlanHash`, { line });
|
|
1671
|
+
const candidateMembershipRevision = requireInteger(record.candidateMembershipRevision, `Milestone ${milestoneId} scope-change candidateMembershipRevision`, { line });
|
|
1672
|
+
const before = parseMilestoneScopeSnapshot(record.before, milestoneId, line, 'before');
|
|
1673
|
+
const after = parseMilestoneScopeSnapshot(record.after, milestoneId, line, 'after');
|
|
1674
|
+
const actor = requireNonEmptyString(record.actor, `Milestone ${milestoneId} scope-change actor`, { line });
|
|
1675
|
+
const confirmationCodeBindingHash = requireHash64(
|
|
1676
|
+
record.confirmationCodeBindingHash,
|
|
1677
|
+
`Milestone ${milestoneId} scope-change confirmationCodeBindingHash`,
|
|
1678
|
+
{ line },
|
|
1679
|
+
);
|
|
1680
|
+
const authorizationMode = record.authorizationMode === undefined
|
|
1681
|
+
? undefined
|
|
1682
|
+
: requireMilestoneScopeChangeAuthorizationMode(record.authorizationMode, milestoneId, line);
|
|
1683
|
+
const autonomyContractEventHash = record.autonomyContractEventHash === undefined
|
|
1684
|
+
? undefined
|
|
1685
|
+
: requireNullableHash64(
|
|
1686
|
+
record.autonomyContractEventHash,
|
|
1687
|
+
`Milestone ${milestoneId} scope-change autonomyContractEventHash`,
|
|
1688
|
+
{ line },
|
|
1689
|
+
);
|
|
1690
|
+
const remediationEvidence = record.remediationEvidence === undefined
|
|
1691
|
+
? undefined
|
|
1692
|
+
: parseAuditedRemediationEvidence(record.remediationEvidence, milestoneId, line);
|
|
1693
|
+
const eventHash = requireHash64(record.eventHash, `Milestone ${milestoneId} scope-change eventHash`, { line });
|
|
1694
|
+
return {
|
|
1695
|
+
eventId,
|
|
1696
|
+
recordedAt,
|
|
1697
|
+
previousEventHash,
|
|
1698
|
+
milestoneId: actualMilestoneId,
|
|
1699
|
+
previousMilestoneRevision,
|
|
1700
|
+
previousPlanHash,
|
|
1701
|
+
previousMembershipRevision,
|
|
1702
|
+
candidatePlanHash,
|
|
1703
|
+
candidateMembershipRevision,
|
|
1704
|
+
before,
|
|
1705
|
+
after,
|
|
1706
|
+
actor,
|
|
1707
|
+
confirmationCodeBindingHash,
|
|
1708
|
+
...(authorizationMode === undefined ? {} : { authorizationMode }),
|
|
1709
|
+
...(autonomyContractEventHash === undefined ? {} : { autonomyContractEventHash }),
|
|
1710
|
+
...(remediationEvidence === undefined ? {} : { remediationEvidence }),
|
|
1711
|
+
eventHash,
|
|
1712
|
+
};
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
function requireMilestoneScopeChangeAuthorizationMode(
|
|
1716
|
+
value: unknown,
|
|
1717
|
+
milestoneId: string,
|
|
1718
|
+
line: number,
|
|
1719
|
+
): 'human' | 'milestone-autonomy' | 'audited-remediation' {
|
|
1720
|
+
if (value !== 'human' && value !== 'milestone-autonomy' && value !== 'audited-remediation') {
|
|
1721
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestoneId} scope-change authorizationMode is invalid.`, {
|
|
1722
|
+
line,
|
|
1723
|
+
value,
|
|
1724
|
+
});
|
|
1725
|
+
}
|
|
1726
|
+
return value;
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
function parseAuditedRemediationEvidence(
|
|
1730
|
+
value: unknown,
|
|
1731
|
+
milestoneId: string,
|
|
1732
|
+
line: number,
|
|
1733
|
+
): AuditedRemediationEvidence {
|
|
1734
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
1735
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestoneId} remediation evidence must be an object.`, { line });
|
|
1736
|
+
}
|
|
1737
|
+
const record = value as Record<string, unknown>;
|
|
1738
|
+
const allowedKeys = new Set([
|
|
1739
|
+
'blockedTaskId',
|
|
1740
|
+
'blockedTaskRevision',
|
|
1741
|
+
'blockedBriefHash',
|
|
1742
|
+
'blockedPlanHash',
|
|
1743
|
+
'planRiskAuditEventId',
|
|
1744
|
+
'planRiskAuditEventHash',
|
|
1745
|
+
'remediationTaskId',
|
|
1746
|
+
'predecessorTaskIds',
|
|
1747
|
+
]);
|
|
1748
|
+
const extraKeys = Object.keys(record).filter((key) => !allowedKeys.has(key));
|
|
1749
|
+
if (extraKeys.length > 0) {
|
|
1750
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestoneId} remediation evidence contains unexpected fields.`, {
|
|
1751
|
+
line,
|
|
1752
|
+
extraKeys,
|
|
1753
|
+
});
|
|
1754
|
+
}
|
|
1755
|
+
const evidence: AuditedRemediationEvidence = {
|
|
1756
|
+
blockedTaskId: requireNonEmptyString(record.blockedTaskId, 'remediation blockedTaskId', { line }),
|
|
1757
|
+
blockedTaskRevision: requireInteger(record.blockedTaskRevision, 'remediation blockedTaskRevision', { line }),
|
|
1758
|
+
blockedBriefHash: requireHash64(record.blockedBriefHash, 'remediation blockedBriefHash', { line }),
|
|
1759
|
+
blockedPlanHash: requireHash64(record.blockedPlanHash, 'remediation blockedPlanHash', { line }),
|
|
1760
|
+
planRiskAuditEventId: requireNonEmptyString(record.planRiskAuditEventId, 'remediation planRiskAuditEventId', { line }),
|
|
1761
|
+
planRiskAuditEventHash: requireHash64(record.planRiskAuditEventHash, 'remediation planRiskAuditEventHash', { line }),
|
|
1762
|
+
remediationTaskId: requireNonEmptyString(record.remediationTaskId, 'remediation remediationTaskId', { line }),
|
|
1763
|
+
predecessorTaskIds: parseTaskIdArray(record.predecessorTaskIds, 'remediation predecessorTaskIds', { line }),
|
|
1764
|
+
};
|
|
1765
|
+
validateAuditedRemediationEvidence(evidence, evidence.planRiskAuditEventId);
|
|
1766
|
+
return evidence;
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
function validateAuditedRemediationEvidence(
|
|
1770
|
+
evidence: AuditedRemediationEvidence,
|
|
1771
|
+
eventId: string,
|
|
1772
|
+
): void {
|
|
1773
|
+
if (!Number.isInteger(evidence.blockedTaskRevision) || evidence.blockedTaskRevision < 1) {
|
|
1774
|
+
throw new WorkflowError('STATE_CORRUPT', 'Audited remediation blocked Task revision is invalid.', {
|
|
1775
|
+
eventId,
|
|
1776
|
+
blockedTaskRevision: evidence.blockedTaskRevision,
|
|
1777
|
+
});
|
|
1778
|
+
}
|
|
1779
|
+
assertHash64(evidence.blockedBriefHash, 'Audited remediation blocked Brief hash');
|
|
1780
|
+
assertHash64(evidence.blockedPlanHash, 'Audited remediation blocked Plan hash');
|
|
1781
|
+
assertHash64(evidence.planRiskAuditEventHash, 'Audited remediation Plan Risk Audit event hash');
|
|
1782
|
+
if (
|
|
1783
|
+
!evidence.blockedTaskId.trim()
|
|
1784
|
+
|| !evidence.planRiskAuditEventId.trim()
|
|
1785
|
+
|| !evidence.remediationTaskId.trim()
|
|
1786
|
+
|| evidence.predecessorTaskIds.length === 0
|
|
1787
|
+
|| new Set(evidence.predecessorTaskIds).size !== evidence.predecessorTaskIds.length
|
|
1788
|
+
|| evidence.predecessorTaskIds.includes(evidence.blockedTaskId)
|
|
1789
|
+
|| evidence.predecessorTaskIds.includes(evidence.remediationTaskId)
|
|
1790
|
+
) {
|
|
1791
|
+
throw new WorkflowError('STATE_CORRUPT', 'Audited remediation evidence has invalid Task bindings.', {
|
|
1792
|
+
eventId,
|
|
1793
|
+
evidence,
|
|
1794
|
+
});
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
function validateAuditedRemediationTopologyEvent(event: MilestoneScopeChangeEvent): void {
|
|
1799
|
+
const evidence = event.remediationEvidence!;
|
|
1800
|
+
const semanticsBefore = {
|
|
1801
|
+
outcome: event.before.outcome,
|
|
1802
|
+
successSignal: event.before.successSignal,
|
|
1803
|
+
acceptance: event.before.acceptance,
|
|
1804
|
+
checks: event.before.checks,
|
|
1805
|
+
};
|
|
1806
|
+
const semanticsAfter = {
|
|
1807
|
+
outcome: event.after.outcome,
|
|
1808
|
+
successSignal: event.after.successSignal,
|
|
1809
|
+
acceptance: event.after.acceptance,
|
|
1810
|
+
checks: event.after.checks,
|
|
1811
|
+
};
|
|
1812
|
+
const expectedTaskIds = [...event.before.taskIds, evidence.remediationTaskId];
|
|
1813
|
+
const beforeBlocked = event.before.memberships.find((membership) => membership.taskId === evidence.blockedTaskId);
|
|
1814
|
+
const afterBlocked = event.after.memberships.find((membership) => membership.taskId === evidence.blockedTaskId);
|
|
1815
|
+
const remediation = event.after.memberships.find((membership) => membership.taskId === evidence.remediationTaskId);
|
|
1816
|
+
const expectedBlockedDependencies = [...new Set([
|
|
1817
|
+
...(beforeBlocked?.dependsOnTaskIds ?? []),
|
|
1818
|
+
evidence.remediationTaskId,
|
|
1819
|
+
])].sort();
|
|
1820
|
+
const unchangedMemberships = event.before.memberships.every((beforeMembership) => {
|
|
1821
|
+
if (beforeMembership.taskId === evidence.blockedTaskId) return true;
|
|
1822
|
+
const afterMembership = event.after.memberships.find(
|
|
1823
|
+
(candidate) => candidate.taskId === beforeMembership.taskId,
|
|
1824
|
+
);
|
|
1825
|
+
return canonicalJsonStringify(afterMembership) === canonicalJsonStringify(beforeMembership);
|
|
1826
|
+
});
|
|
1827
|
+
if (canonicalJsonStringify(semanticsBefore) !== canonicalJsonStringify(semanticsAfter)
|
|
1828
|
+
|| canonicalJsonStringify(event.after.taskIds) !== canonicalJsonStringify(expectedTaskIds)
|
|
1829
|
+
|| event.after.memberships.length !== event.before.memberships.length + 1
|
|
1830
|
+
|| beforeBlocked?.disposition !== 'required'
|
|
1831
|
+
|| afterBlocked?.disposition !== 'required'
|
|
1832
|
+
|| canonicalJsonStringify(afterBlocked?.dependsOnTaskIds ?? [])
|
|
1833
|
+
!== canonicalJsonStringify(expectedBlockedDependencies)
|
|
1834
|
+
|| remediation?.disposition !== 'required'
|
|
1835
|
+
|| !remediation.reason.trim()
|
|
1836
|
+
|| canonicalJsonStringify(remediation.dependsOnTaskIds ?? [])
|
|
1837
|
+
!== canonicalJsonStringify(evidence.predecessorTaskIds)
|
|
1838
|
+
|| canonicalJsonStringify(beforeBlocked?.dependsOnTaskIds ?? [])
|
|
1839
|
+
!== canonicalJsonStringify(evidence.predecessorTaskIds)
|
|
1840
|
+
|| !unchangedMemberships) {
|
|
1841
|
+
throw new WorkflowError(
|
|
1842
|
+
'STATE_CORRUPT',
|
|
1843
|
+
'Audited remediation scope-change event exceeds its additive topology authority.',
|
|
1844
|
+
{ eventId: event.eventId, remediationEvidence: evidence },
|
|
1845
|
+
);
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
function parseMilestoneScopeSnapshot(
|
|
1850
|
+
value: unknown,
|
|
1851
|
+
milestoneId: string,
|
|
1852
|
+
line: number,
|
|
1853
|
+
label: 'before' | 'after',
|
|
1854
|
+
): MilestoneScopeSnapshot {
|
|
1855
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
1856
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestoneId} scope-change ${label} snapshot must be an object.`, { line });
|
|
1857
|
+
}
|
|
1858
|
+
const record = value as Record<string, unknown>;
|
|
1859
|
+
const allowedKeys = new Set(['outcome', 'successSignal', 'acceptance', 'checks', 'taskIds', 'memberships']);
|
|
1860
|
+
const extraKeys = Object.keys(record).filter((key) => !allowedKeys.has(key));
|
|
1861
|
+
if (extraKeys.length > 0) {
|
|
1862
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestoneId} scope-change ${label} snapshot contains unexpected fields.`, {
|
|
1863
|
+
line,
|
|
1864
|
+
extraKeys,
|
|
1865
|
+
});
|
|
1866
|
+
}
|
|
1867
|
+
const snapshot = {
|
|
1868
|
+
outcome: requireNonEmptyString(record.outcome, `Milestone ${milestoneId} scope-change ${label}.outcome`, { line }),
|
|
1869
|
+
successSignal: requireNonEmptyString(record.successSignal, `Milestone ${milestoneId} scope-change ${label}.successSignal`, { line }),
|
|
1870
|
+
acceptance: parseStringArray(record.acceptance, `Milestone ${milestoneId} scope-change ${label}.acceptance`, { line }),
|
|
1871
|
+
checks: parseStringArray(record.checks, `Milestone ${milestoneId} scope-change ${label}.checks`, { line }),
|
|
1872
|
+
taskIds: parseTaskIdArray(record.taskIds, `Milestone ${milestoneId} scope-change ${label}.taskIds`, { line }),
|
|
1873
|
+
memberships: parseMilestoneMembershipArray(record.memberships, milestoneId, { line, label }),
|
|
1874
|
+
};
|
|
1875
|
+
assertSnapshotTaskIdsMatchMemberships(snapshot, milestoneId, { line, label });
|
|
1876
|
+
return snapshot;
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
function parseMilestoneMembershipArray(
|
|
1880
|
+
value: unknown,
|
|
1881
|
+
milestoneId: string,
|
|
1882
|
+
options: { line?: number; label?: string } = {},
|
|
1883
|
+
): MilestoneMembership[] {
|
|
1884
|
+
const label = options.label ? `${options.label}.memberships` : 'memberships';
|
|
1885
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
1886
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestoneId} ${label} must be a non-empty array.`, options.line !== undefined ? { line: options.line } : {});
|
|
1887
|
+
}
|
|
1888
|
+
const memberships = value.map((entry, index) => parseMilestoneMembership(entry, milestoneId, index, options.line));
|
|
1889
|
+
const taskIds = memberships.map((membership) => membership.taskId);
|
|
1890
|
+
if (new Set(taskIds).size !== taskIds.length) {
|
|
1891
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestoneId} ${label} contains duplicate taskIds.`, options.line !== undefined ? { line: options.line, taskIds } : { taskIds });
|
|
1892
|
+
}
|
|
1893
|
+
return memberships;
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
function parseMilestoneMembership(
|
|
1897
|
+
value: unknown,
|
|
1898
|
+
milestoneId: string,
|
|
1899
|
+
index: number,
|
|
1900
|
+
line?: number,
|
|
1901
|
+
): MilestoneMembership {
|
|
1902
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
1903
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestoneId} membership at index ${index} must be an object.`, line !== undefined ? { line, index } : { index });
|
|
1904
|
+
}
|
|
1905
|
+
const record = value as Record<string, unknown>;
|
|
1906
|
+
const allowedKeys = new Set(['taskId', 'disposition', 'reason', 'dependsOnTaskIds']);
|
|
1907
|
+
const extraKeys = Object.keys(record).filter((key) => !allowedKeys.has(key));
|
|
1908
|
+
if (extraKeys.length > 0) {
|
|
1909
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestoneId} membership contains unexpected fields.`, line !== undefined ? { line, index, extraKeys } : { index, extraKeys });
|
|
1910
|
+
}
|
|
1911
|
+
const taskId = requireNonEmptyString(record.taskId, `Milestone ${milestoneId} membership.taskId`, line !== undefined ? { line, index } : { index });
|
|
1912
|
+
const dispositionValue = requireNonEmptyString(record.disposition, `Milestone ${milestoneId} membership.disposition`, line !== undefined ? { line, index } : { index });
|
|
1913
|
+
if (!['required', 'waived', 'cancelled'].includes(dispositionValue)) {
|
|
1914
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestoneId} membership disposition is invalid.`, line !== undefined ? { line, index, disposition: dispositionValue } : { index, disposition: dispositionValue });
|
|
1915
|
+
}
|
|
1916
|
+
const disposition = dispositionValue as MilestoneMembership['disposition'];
|
|
1917
|
+
const reason = typeof record.reason === 'string' ? record.reason.trim() : '';
|
|
1918
|
+
if (disposition !== 'required' && reason.length === 0) {
|
|
1919
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestoneId} membership ${disposition} requires a reason.`, line !== undefined ? { line, index } : { index });
|
|
1920
|
+
}
|
|
1921
|
+
const dependsOnTaskIds = record.dependsOnTaskIds === undefined
|
|
1922
|
+
? undefined
|
|
1923
|
+
: parseTaskDependencyArray(
|
|
1924
|
+
record.dependsOnTaskIds,
|
|
1925
|
+
`Milestone ${milestoneId} membership.dependsOnTaskIds`,
|
|
1926
|
+
line !== undefined ? { line, index } : { index },
|
|
1927
|
+
);
|
|
1928
|
+
return {
|
|
1929
|
+
taskId,
|
|
1930
|
+
disposition,
|
|
1931
|
+
reason,
|
|
1932
|
+
...(dependsOnTaskIds === undefined ? {} : { dependsOnTaskIds }),
|
|
1933
|
+
};
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
function parseTaskDependencyArray(
|
|
1937
|
+
value: unknown,
|
|
1938
|
+
label: string,
|
|
1939
|
+
details: Record<string, unknown> = {},
|
|
1940
|
+
): string[] {
|
|
1941
|
+
if (!Array.isArray(value)) {
|
|
1942
|
+
throw new WorkflowError('STATE_CORRUPT', `${label} must be an array.`, details);
|
|
1943
|
+
}
|
|
1944
|
+
const taskIds = value.map((entry, index) => {
|
|
1945
|
+
if (typeof entry !== 'string' || !entry.trim()) {
|
|
1946
|
+
throw new WorkflowError('STATE_CORRUPT', `${label}[${index}] must be a non-empty Task ID.`, {
|
|
1947
|
+
...details,
|
|
1948
|
+
index,
|
|
1949
|
+
});
|
|
1950
|
+
}
|
|
1951
|
+
return entry.trim();
|
|
1952
|
+
});
|
|
1953
|
+
if (new Set(taskIds).size !== taskIds.length) {
|
|
1954
|
+
throw new WorkflowError('STATE_CORRUPT', `${label} contains duplicate Task IDs.`, { ...details, taskIds });
|
|
1955
|
+
}
|
|
1956
|
+
return taskIds;
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
function parseStringArray(value: unknown, label: string, details: Record<string, unknown> = {}): string[] {
|
|
1960
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
1961
|
+
throw new WorkflowError('STATE_CORRUPT', `${label} must be a non-empty array.`, details);
|
|
1962
|
+
}
|
|
1963
|
+
return value.map((entry, index) => {
|
|
1964
|
+
if (typeof entry !== 'string' || !entry.trim()) {
|
|
1965
|
+
throw new WorkflowError('STATE_CORRUPT', `${label}[${index}] must be a non-empty string.`, { ...details, index });
|
|
1966
|
+
}
|
|
1967
|
+
return entry;
|
|
1968
|
+
});
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
function parseTaskIdArray(value: unknown, label: string, details: Record<string, unknown> = {}): string[] {
|
|
1972
|
+
const taskIds = parseStringArray(value, label, details);
|
|
1973
|
+
if (new Set(taskIds).size !== taskIds.length) {
|
|
1974
|
+
throw new WorkflowError('STATE_CORRUPT', `${label} contains duplicate taskIds.`, { ...details, taskIds });
|
|
1975
|
+
}
|
|
1976
|
+
return taskIds;
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
function requireNonEmptyString(value: unknown, label: string, details: Record<string, unknown> = {}): string {
|
|
1980
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
1981
|
+
throw new WorkflowError('STATE_CORRUPT', `${label} must be a non-empty string.`, details);
|
|
1982
|
+
}
|
|
1983
|
+
return value;
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
function requireInteger(value: unknown, label: string, details: Record<string, unknown> = {}): number {
|
|
1987
|
+
if (!Number.isInteger(value)) {
|
|
1988
|
+
throw new WorkflowError('STATE_CORRUPT', `${label} must be an integer.`, details);
|
|
1989
|
+
}
|
|
1990
|
+
return value as number;
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
function requireHash64(value: unknown, label: string, details: Record<string, unknown> = {}): string {
|
|
1994
|
+
if (typeof value !== 'string') {
|
|
1995
|
+
throw new WorkflowError('STATE_CORRUPT', `${label} must be a sha256 hex digest.`, details);
|
|
1996
|
+
}
|
|
1997
|
+
assertHash64(value, label);
|
|
1998
|
+
return value;
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
function requireNullableHash64(value: unknown, label: string, details: Record<string, unknown> = {}): string | null {
|
|
2002
|
+
if (value === null) return null;
|
|
2003
|
+
return requireHash64(value, label, details);
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
function requireIsoTimestamp(value: unknown, label: string, details: Record<string, unknown> = {}): string {
|
|
2007
|
+
if (typeof value !== 'string' || !value.trim() || Number.isNaN(Date.parse(value))) {
|
|
2008
|
+
throw new WorkflowError('STATE_CORRUPT', `${label} must be an ISO timestamp string.`, details);
|
|
2009
|
+
}
|
|
2010
|
+
return value;
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
function milestoneScopeSnapshotFromPlan(
|
|
2014
|
+
plan: NormalizedMilestonePlan | PersistedMilestonePlan,
|
|
2015
|
+
): MilestoneScopeSnapshot {
|
|
2016
|
+
return {
|
|
2017
|
+
outcome: plan.outcome,
|
|
2018
|
+
successSignal: plan.successSignal,
|
|
2019
|
+
acceptance: plan.acceptance,
|
|
2020
|
+
checks: plan.checks,
|
|
2021
|
+
taskIds: plan.taskIds,
|
|
2022
|
+
memberships: plan.memberships,
|
|
2023
|
+
};
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
function renderMilestonePlanArtifact(plan: NormalizedMilestonePlan): string {
|
|
2027
|
+
return `${JSON.stringify({
|
|
2028
|
+
outcome: plan.outcome,
|
|
2029
|
+
successSignal: plan.successSignal,
|
|
2030
|
+
acceptance: plan.acceptance,
|
|
2031
|
+
memberships: plan.memberships,
|
|
2032
|
+
checks: plan.checks,
|
|
2033
|
+
}, null, 2)}\n`;
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
function milestoneScopeChangeConfirmationCode(bindingHash: string): string {
|
|
2037
|
+
return `MSC-${bindingHash.slice(0, 24).toUpperCase()}`;
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
function computeMilestoneScopeChangeBindingHash(input: {
|
|
2041
|
+
milestoneId: string;
|
|
2042
|
+
expectedRevision: number;
|
|
2043
|
+
previousPlanHash: string | null;
|
|
2044
|
+
previousMembershipRevision: number;
|
|
2045
|
+
candidatePlanHash: string;
|
|
2046
|
+
candidateMembershipRevision: number;
|
|
2047
|
+
actor: string;
|
|
2048
|
+
after: MilestoneScopeSnapshot;
|
|
2049
|
+
authorizationMode?: 'human' | 'milestone-autonomy' | 'audited-remediation';
|
|
2050
|
+
autonomyContractEventHash?: string | null;
|
|
2051
|
+
remediationEvidence?: AuditedRemediationEvidence | null;
|
|
2052
|
+
}): string {
|
|
2053
|
+
return sha256Hex(canonicalJsonStringify({
|
|
2054
|
+
kind: 'milestone-scope-change-v1',
|
|
2055
|
+
milestoneId: input.milestoneId,
|
|
2056
|
+
expectedRevision: input.expectedRevision,
|
|
2057
|
+
previousPlanHash: input.previousPlanHash,
|
|
2058
|
+
previousMembershipRevision: input.previousMembershipRevision,
|
|
2059
|
+
candidatePlanHash: input.candidatePlanHash,
|
|
2060
|
+
candidateMembershipRevision: input.candidateMembershipRevision,
|
|
2061
|
+
actor: input.actor,
|
|
2062
|
+
after: input.after,
|
|
2063
|
+
...(input.authorizationMode === 'milestone-autonomy'
|
|
2064
|
+
? {
|
|
2065
|
+
authorizationMode: input.authorizationMode,
|
|
2066
|
+
autonomyContractEventHash: input.autonomyContractEventHash,
|
|
2067
|
+
}
|
|
2068
|
+
: input.authorizationMode === 'audited-remediation'
|
|
2069
|
+
? {
|
|
2070
|
+
authorizationMode: input.authorizationMode,
|
|
2071
|
+
remediationEvidence: input.remediationEvidence,
|
|
2072
|
+
}
|
|
2073
|
+
: {}),
|
|
2074
|
+
}));
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
function assertSnapshotTaskIdsMatchMemberships(
|
|
2078
|
+
snapshot: MilestoneScopeSnapshot,
|
|
2079
|
+
milestoneId: string,
|
|
2080
|
+
details: Record<string, unknown> = {},
|
|
2081
|
+
): void {
|
|
2082
|
+
const membershipTaskIds = snapshot.memberships.map((membership) => membership.taskId);
|
|
2083
|
+
const taskIds = [...snapshot.taskIds];
|
|
2084
|
+
if (
|
|
2085
|
+
membershipTaskIds.length !== taskIds.length
|
|
2086
|
+
|| membershipTaskIds.some((taskId) => !taskIds.includes(taskId))
|
|
2087
|
+
|| taskIds.some((taskId) => !membershipTaskIds.includes(taskId))
|
|
2088
|
+
) {
|
|
2089
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone ${milestoneId} scope snapshot taskIds and memberships diverged.`, {
|
|
2090
|
+
...details,
|
|
2091
|
+
taskIds,
|
|
2092
|
+
membershipTaskIds,
|
|
2093
|
+
});
|
|
2094
|
+
}
|
|
2095
|
+
}
|
|
2096
|
+
|
|
2097
|
+
function parseStagedMilestoneStateArtifact(
|
|
2098
|
+
content: string,
|
|
2099
|
+
expectedProjectId: string,
|
|
2100
|
+
milestoneId: string,
|
|
2101
|
+
transactionId: string,
|
|
2102
|
+
): MilestoneState {
|
|
2103
|
+
let parsed: unknown;
|
|
2104
|
+
try {
|
|
2105
|
+
parsed = JSON.parse(content);
|
|
2106
|
+
} catch (error) {
|
|
2107
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone transaction ${transactionId} staged state.json is not valid JSON.`, {
|
|
2108
|
+
milestoneId,
|
|
2109
|
+
cause: error instanceof Error ? error.message : String(error),
|
|
2110
|
+
});
|
|
2111
|
+
}
|
|
2112
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
2113
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone transaction ${transactionId} staged state.json must be an object.`, {
|
|
2114
|
+
milestoneId,
|
|
2115
|
+
});
|
|
2116
|
+
}
|
|
2117
|
+
const record = parsed as Record<string, unknown>;
|
|
2118
|
+
if (record.kind !== 'milestone') {
|
|
2119
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone transaction ${transactionId} staged state kind is invalid.`, {
|
|
2120
|
+
milestoneId,
|
|
2121
|
+
kind: record.kind,
|
|
2122
|
+
});
|
|
2123
|
+
}
|
|
2124
|
+
if (record.id !== milestoneId || record.projectId !== expectedProjectId) {
|
|
2125
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone transaction ${transactionId} staged state identity is invalid.`, {
|
|
2126
|
+
milestoneId,
|
|
2127
|
+
expectedProjectId,
|
|
2128
|
+
id: record.id,
|
|
2129
|
+
projectId: record.projectId,
|
|
2130
|
+
});
|
|
2131
|
+
}
|
|
2132
|
+
if (!Number.isInteger(record.revision)) {
|
|
2133
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone transaction ${transactionId} staged state revision is invalid.`, {
|
|
2134
|
+
milestoneId,
|
|
2135
|
+
revision: record.revision,
|
|
2136
|
+
});
|
|
2137
|
+
}
|
|
2138
|
+
if (
|
|
2139
|
+
typeof record.status !== 'string'
|
|
2140
|
+
|| !['planning', 'awaiting_execution_authorization', 'active', 'validating', 'accepted', 'blocked', 'cancelled'].includes(record.status)
|
|
2141
|
+
) {
|
|
2142
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone transaction ${transactionId} staged state status is invalid.`, {
|
|
2143
|
+
milestoneId,
|
|
2144
|
+
status: record.status,
|
|
2145
|
+
});
|
|
2146
|
+
}
|
|
2147
|
+
if (!Array.isArray(record.taskIds) || !Array.isArray(record.memberships) || !Array.isArray(record.authorizations)) {
|
|
2148
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone transaction ${transactionId} staged state arrays are invalid.`, {
|
|
2149
|
+
milestoneId,
|
|
2150
|
+
});
|
|
2151
|
+
}
|
|
2152
|
+
return parsed as MilestoneState;
|
|
2153
|
+
}
|
|
2154
|
+
|
|
2155
|
+
function resolveAlpha6MilestoneRoot(store: FileStateStore, projectId: string, milestoneId: string): string {
|
|
2156
|
+
const projectRootLogical = store.projectRoot(projectId);
|
|
2157
|
+
const trustedProjectRoot = existsSync(projectRootLogical) ? realpathSync(projectRootLogical) : path.resolve(projectRootLogical);
|
|
2158
|
+
const milestonesDirLogical = path.join(projectRootLogical, 'milestones');
|
|
2159
|
+
const trustedMilestonesDir = existsSync(milestonesDirLogical)
|
|
2160
|
+
? realpathSync(milestonesDirLogical)
|
|
2161
|
+
: path.join(trustedProjectRoot, 'milestones');
|
|
2162
|
+
const milestoneRootLogical = store.milestoneRoot(projectId, milestoneId);
|
|
2163
|
+
assertNoSymlinkPathComponents(trustedMilestonesDir, { label: 'Trusted milestones directory', requireLeaf: true });
|
|
2164
|
+
if (existsSync(milestoneRootLogical) && lstatSync(milestoneRootLogical).isSymbolicLink()) {
|
|
2165
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone root must not be a symlink: ${milestoneRootLogical}`);
|
|
2166
|
+
}
|
|
2167
|
+
const resolvedMilestoneRoot = existsSync(milestoneRootLogical)
|
|
2168
|
+
? realpathSync(milestoneRootLogical)
|
|
2169
|
+
: path.resolve(trustedMilestonesDir, milestoneId);
|
|
2170
|
+
if (
|
|
2171
|
+
resolvedMilestoneRoot !== trustedMilestonesDir
|
|
2172
|
+
&& !resolvedMilestoneRoot.startsWith(`${trustedMilestonesDir}${path.sep}`)
|
|
2173
|
+
) {
|
|
2174
|
+
throw new WorkflowError('STATE_CORRUPT', `Milestone root escaped the trusted milestones directory: ${milestoneRootLogical}`, {
|
|
2175
|
+
trustedMilestonesDir,
|
|
2176
|
+
resolvedMilestoneRoot,
|
|
2177
|
+
});
|
|
2178
|
+
}
|
|
2179
|
+
return resolvedMilestoneRoot;
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
function taskHasStarted(task: TaskState): boolean {
|
|
2183
|
+
return task.workspaceOwner !== null || [
|
|
2184
|
+
'in_progress',
|
|
2185
|
+
'validating',
|
|
2186
|
+
'needs_fix',
|
|
2187
|
+
'awaiting_final_acceptance',
|
|
2188
|
+
'merging',
|
|
2189
|
+
'ready_to_merge',
|
|
2190
|
+
'merged',
|
|
2191
|
+
].includes(task.status);
|
|
2192
|
+
}
|