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,2438 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import type {
|
|
4
|
+
CorrectiveDecisionEvent,
|
|
5
|
+
CorrectiveDecisionRecoveryEvent,
|
|
6
|
+
CorrectivePlanAuditInput,
|
|
7
|
+
GuardedRiskCategory,
|
|
8
|
+
PlanIntegrityConflictEvidence,
|
|
9
|
+
PlanRiskAuditEvent,
|
|
10
|
+
RemediationEvent,
|
|
11
|
+
RemediationCauseBinding,
|
|
12
|
+
RemediationFailureEvidence,
|
|
13
|
+
RemediationFailureKind,
|
|
14
|
+
RemediationMode,
|
|
15
|
+
RemediationModeRecoveryEvent,
|
|
16
|
+
StopEscalateOverrideEvent,
|
|
17
|
+
TaskState,
|
|
18
|
+
} from '../contracts.js';
|
|
19
|
+
import { assertKnowledgeBindingOnlyPlanChange, normalizePlanKnowledgeBinding, readVerifiedPlanArtifact } from '../domain/plan-semantics.js';
|
|
20
|
+
import { pathAllowed } from '../domain/validation.js';
|
|
21
|
+
import { WorkflowError } from '../errors.js';
|
|
22
|
+
import { hashCanonical } from '../lifecycle/fingerprint.js';
|
|
23
|
+
import type { FileStateStore } from '../state/store.js';
|
|
24
|
+
import { createUlid } from '../ulid.js';
|
|
25
|
+
import { readPlanRiskAuditEvents, type CurrentPlanRiskAudit } from './plan-risk.js';
|
|
26
|
+
import {
|
|
27
|
+
assertHash64,
|
|
28
|
+
canonicalJsonStringify,
|
|
29
|
+
prepareSidecarAppend,
|
|
30
|
+
sha256Hex,
|
|
31
|
+
} from './store-sidecars.js';
|
|
32
|
+
import {
|
|
33
|
+
buildCurrentStrictStepReviewCycle,
|
|
34
|
+
latestPassedVerifiedStepReviewEvent,
|
|
35
|
+
readReviewerAttestations,
|
|
36
|
+
readStepReviewEvents,
|
|
37
|
+
} from './review.js';
|
|
38
|
+
import { correctiveReplanBoundaryMatchesStep, readCompletedCorrectiveReplanHistory } from '../state/corrective-replan-executor.js';
|
|
39
|
+
import { semanticBoundaryHash, validLiteralTestInvocationEvidence } from './component-owner.js';
|
|
40
|
+
import { reviewerCauseFingerprint } from './remediation-cause.js';
|
|
41
|
+
|
|
42
|
+
export const REMEDIATION_EVENTS_SIDECAR = 'remediation-events.jsonl';
|
|
43
|
+
export const CORRECTIVE_DECISIONS_SIDECAR = 'corrective-decisions.jsonl';
|
|
44
|
+
export const CORRECTIVE_DECISION_RECOVERIES_SIDECAR = 'corrective-decision-recoveries.jsonl';
|
|
45
|
+
export const REMEDIATION_MODE_RECOVERIES_SIDECAR = 'remediation-mode-recoveries.jsonl';
|
|
46
|
+
export const STOP_ESCALATE_OVERRIDES_SIDECAR = 'stop-escalate-overrides.jsonl';
|
|
47
|
+
const STOP_ESCALATE_OVERRIDE_LOCK = '.stop-escalate-override.lock';
|
|
48
|
+
const LEGACY_STOP_OVERRIDE_POLICY = 'beta11-attempt4-stop-compatibility-v1' as const;
|
|
49
|
+
|
|
50
|
+
const COMMIT_SHA_PATTERN = /^[a-f0-9]{40}$/;
|
|
51
|
+
const REMEDIATION_FAILURE_KINDS = new Set<RemediationFailureKind>([
|
|
52
|
+
'checks-failed',
|
|
53
|
+
'review-failed',
|
|
54
|
+
'review-unverified',
|
|
55
|
+
]);
|
|
56
|
+
const REMEDIATION_MODES = new Set<RemediationMode>(['ordinary', 'corrective']);
|
|
57
|
+
const CORRECTIVE_DECISION_VALUES = new Set<CorrectiveDecisionEvent['decision']>([
|
|
58
|
+
'continue-fix',
|
|
59
|
+
'replan-required',
|
|
60
|
+
'split-required',
|
|
61
|
+
'stop-escalate',
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
export interface GuardedRemediationAttemptGate {
|
|
65
|
+
stepId: string;
|
|
66
|
+
guardedCategory: GuardedRiskCategory;
|
|
67
|
+
attemptCount: number;
|
|
68
|
+
nextAttemptOrdinal: number;
|
|
69
|
+
mode: RemediationMode;
|
|
70
|
+
correctiveDecision: CorrectiveDecisionEvent | null;
|
|
71
|
+
posture:
|
|
72
|
+
| 'ordinary'
|
|
73
|
+
| 'decision-required'
|
|
74
|
+
| 'continue-fix'
|
|
75
|
+
| 'replan-required'
|
|
76
|
+
| 'replan-ready'
|
|
77
|
+
| 'split-required'
|
|
78
|
+
| 'stop-escalate';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface PreparedRemediationEvent {
|
|
82
|
+
event: RemediationEvent;
|
|
83
|
+
postimage: string | null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function readRemediationEvents(
|
|
87
|
+
store: FileStateStore,
|
|
88
|
+
task: TaskState,
|
|
89
|
+
): RemediationEvent[] {
|
|
90
|
+
return validateRemediationHistory(store, task).remediationEvents;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function readCorrectiveDecisionEvents(
|
|
94
|
+
store: FileStateStore,
|
|
95
|
+
task: TaskState,
|
|
96
|
+
): CorrectiveDecisionEvent[] {
|
|
97
|
+
return validateRemediationHistory(store, task).correctiveDecisionEvents;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function defaultCorrectiveAuditorActor(taskId: string): string {
|
|
101
|
+
if (!/^TASK-[0-9A-HJKMNP-TV-Z]{26}$/.test(taskId)) {
|
|
102
|
+
throw new WorkflowError('INVALID_ARGUMENT', `Cannot derive a corrective Auditor actor from invalid Task ID ${taskId}.`);
|
|
103
|
+
}
|
|
104
|
+
return `agent:corrective-auditor:${taskId.toLowerCase()}`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function assertCorrectiveAuditorIndependence(
|
|
108
|
+
store: FileStateStore,
|
|
109
|
+
task: TaskState,
|
|
110
|
+
stepId: string,
|
|
111
|
+
auditor: string,
|
|
112
|
+
): void {
|
|
113
|
+
const remediationEvents = readRemediationEvents(store, task).filter((event) => event.stepId === stepId);
|
|
114
|
+
const latestRemediationEvent = remediationEvents.at(-1) ?? null;
|
|
115
|
+
if (!latestRemediationEvent || latestRemediationEvent.failureKind === 'checks-failed') return;
|
|
116
|
+
const reviewerAttestationHash = latestRemediationEvent.failureEvidence.reviewerAttestationHash;
|
|
117
|
+
if (!reviewerAttestationHash) {
|
|
118
|
+
throw new WorkflowError('STATE_CORRUPT', 'Latest guarded remediation event is missing its strict-review attestation binding.', {
|
|
119
|
+
taskId: task.id,
|
|
120
|
+
stepId,
|
|
121
|
+
eventId: latestRemediationEvent.eventId,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
const attestation = readReviewerAttestations(store, task)
|
|
125
|
+
.find((event) => event.eventHash === reviewerAttestationHash) ?? null;
|
|
126
|
+
if (!attestation) {
|
|
127
|
+
throw new WorkflowError('STATE_CORRUPT', 'Latest guarded remediation event references a missing reviewer attestation.', {
|
|
128
|
+
taskId: task.id,
|
|
129
|
+
stepId,
|
|
130
|
+
reviewerAttestationHash,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
if (attestation.reviewer.trim() === auditor.trim()) {
|
|
134
|
+
throw new WorkflowError('TRANSITION_BLOCKED', `Corrective decision for ${stepId} must be recorded by an auditor distinct from the latest strict reviewer.`, {
|
|
135
|
+
taskId: task.id,
|
|
136
|
+
stepId,
|
|
137
|
+
reviewer: attestation.reviewer,
|
|
138
|
+
auditor: auditor.trim(),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function validateRemediationHistory(
|
|
144
|
+
store: FileStateStore,
|
|
145
|
+
task: TaskState,
|
|
146
|
+
): {
|
|
147
|
+
remediationEvents: RemediationEvent[];
|
|
148
|
+
correctiveDecisionEvents: CorrectiveDecisionEvent[];
|
|
149
|
+
} {
|
|
150
|
+
const remediationEvents = readRawRemediationEvents(store, task);
|
|
151
|
+
const correctiveDecisionEvents = readRawCorrectiveDecisionEvents(store, task);
|
|
152
|
+
const modeRecoveries = readRemediationModeRecoveryEvents(store, task);
|
|
153
|
+
const overriddenStopDecisionEventIds = validateStopEscalateOverrideHistory(
|
|
154
|
+
task,
|
|
155
|
+
remediationEvents,
|
|
156
|
+
correctiveDecisionEvents,
|
|
157
|
+
readRawStopEscalateOverrideEvents(store, task),
|
|
158
|
+
);
|
|
159
|
+
validateRemediationAttemptOrdinals(remediationEvents, task);
|
|
160
|
+
validateRemediationReviewReferences(store, task, remediationEvents);
|
|
161
|
+
validateCorrectiveDecisionCoverage(store, task, correctiveDecisionEvents, remediationEvents);
|
|
162
|
+
const recoveredEventIds = validateRemediationModeRecoveries(
|
|
163
|
+
store,
|
|
164
|
+
task,
|
|
165
|
+
remediationEvents,
|
|
166
|
+
correctiveDecisionEvents,
|
|
167
|
+
modeRecoveries,
|
|
168
|
+
);
|
|
169
|
+
validateHistoricalRemediationModesForAllSteps(
|
|
170
|
+
store,
|
|
171
|
+
task,
|
|
172
|
+
remediationEvents,
|
|
173
|
+
correctiveDecisionEvents,
|
|
174
|
+
recoveredEventIds,
|
|
175
|
+
overriddenStopDecisionEventIds,
|
|
176
|
+
);
|
|
177
|
+
return {
|
|
178
|
+
remediationEvents,
|
|
179
|
+
correctiveDecisionEvents,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function readRemediationModeRecoveryEvents(
|
|
184
|
+
store: FileStateStore,
|
|
185
|
+
task: TaskState,
|
|
186
|
+
): RemediationModeRecoveryEvent[] {
|
|
187
|
+
const root = store.taskRoot(task.projectId, task.id);
|
|
188
|
+
const file = path.join(root, REMEDIATION_MODE_RECOVERIES_SIDECAR);
|
|
189
|
+
if (!existsSync(file)) return [];
|
|
190
|
+
return store.readSidecarEvents<RemediationModeRecoveryEvent>(root, REMEDIATION_MODE_RECOVERIES_SIDECAR, {
|
|
191
|
+
validateEvent: (event, context) => validateStoredRemediationModeRecovery(event, task, context.file, context.line),
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function readRawCorrectiveDecisionRecoveries(
|
|
196
|
+
store: FileStateStore,
|
|
197
|
+
task: TaskState,
|
|
198
|
+
): CorrectiveDecisionRecoveryEvent[] {
|
|
199
|
+
const root = store.taskRoot(task.projectId, task.id);
|
|
200
|
+
const file = path.join(root, CORRECTIVE_DECISION_RECOVERIES_SIDECAR);
|
|
201
|
+
if (!existsSync(file)) return [];
|
|
202
|
+
return store.readSidecarEvents<CorrectiveDecisionRecoveryEvent>(root, CORRECTIVE_DECISION_RECOVERIES_SIDECAR, {
|
|
203
|
+
validateEvent: (event, context) => validateStoredCorrectiveDecisionRecoveryReference(event, task, context.file, context.line),
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function readRawRemediationEvents(
|
|
208
|
+
store: FileStateStore,
|
|
209
|
+
task: TaskState,
|
|
210
|
+
): RemediationEvent[] {
|
|
211
|
+
const sidecarFile = path.join(store.taskRoot(task.projectId, task.id), REMEDIATION_EVENTS_SIDECAR);
|
|
212
|
+
if (!existsSync(sidecarFile)) return [];
|
|
213
|
+
return store.readSidecarEvents<RemediationEvent>(
|
|
214
|
+
store.taskRoot(task.projectId, task.id),
|
|
215
|
+
REMEDIATION_EVENTS_SIDECAR,
|
|
216
|
+
{
|
|
217
|
+
validateEvent: (event, context) => validateStoredRemediationEvent(event, task, context.file, context.line),
|
|
218
|
+
},
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function readRawCorrectiveDecisionEvents(
|
|
223
|
+
store: FileStateStore,
|
|
224
|
+
task: TaskState,
|
|
225
|
+
): CorrectiveDecisionEvent[] {
|
|
226
|
+
const sidecarFile = path.join(store.taskRoot(task.projectId, task.id), CORRECTIVE_DECISIONS_SIDECAR);
|
|
227
|
+
if (!existsSync(sidecarFile)) return [];
|
|
228
|
+
return store.readSidecarEvents<CorrectiveDecisionEvent>(
|
|
229
|
+
store.taskRoot(task.projectId, task.id),
|
|
230
|
+
CORRECTIVE_DECISIONS_SIDECAR,
|
|
231
|
+
{
|
|
232
|
+
validateEvent: (event, context) => validateStoredCorrectiveDecisionEvent(event, task, context.file, context.line),
|
|
233
|
+
},
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function readRawStopEscalateOverrideEvents(
|
|
238
|
+
store: FileStateStore,
|
|
239
|
+
task: TaskState,
|
|
240
|
+
): StopEscalateOverrideEvent[] {
|
|
241
|
+
const root = store.taskRoot(task.projectId, task.id);
|
|
242
|
+
const sidecarFile = path.join(root, STOP_ESCALATE_OVERRIDES_SIDECAR);
|
|
243
|
+
if (!existsSync(sidecarFile)) return [];
|
|
244
|
+
return store.readSidecarEvents<StopEscalateOverrideEvent>(root, STOP_ESCALATE_OVERRIDES_SIDECAR, {
|
|
245
|
+
validateEvent: (event, context) => validateStoredStopEscalateOverride(event, task, context.file, context.line),
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function readStopEscalateOverrideEvents(
|
|
250
|
+
store: FileStateStore,
|
|
251
|
+
task: TaskState,
|
|
252
|
+
): StopEscalateOverrideEvent[] {
|
|
253
|
+
const remediations = readRawRemediationEvents(store, task);
|
|
254
|
+
const decisions = readRawCorrectiveDecisionEvents(store, task);
|
|
255
|
+
const overrides = readRawStopEscalateOverrideEvents(store, task);
|
|
256
|
+
validateRemediationAttemptOrdinals(remediations, task);
|
|
257
|
+
validateCorrectiveDecisionCoverage(store, task, decisions, remediations);
|
|
258
|
+
validateStopEscalateOverrideHistory(task, remediations, decisions, overrides);
|
|
259
|
+
return overrides;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function stopEscalateOverrideDecisionEventIds(
|
|
263
|
+
store: FileStateStore,
|
|
264
|
+
task: TaskState,
|
|
265
|
+
): ReadonlySet<string> {
|
|
266
|
+
const remediations = readRawRemediationEvents(store, task);
|
|
267
|
+
const decisions = readRawCorrectiveDecisionEvents(store, task);
|
|
268
|
+
const overrides = readRawStopEscalateOverrideEvents(store, task);
|
|
269
|
+
validateRemediationAttemptOrdinals(remediations, task);
|
|
270
|
+
validateCorrectiveDecisionCoverage(store, task, decisions, remediations);
|
|
271
|
+
return validateStopEscalateOverrideHistory(task, remediations, decisions, overrides);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function appendRemediationEvent(
|
|
275
|
+
store: FileStateStore,
|
|
276
|
+
task: TaskState,
|
|
277
|
+
stepId: string,
|
|
278
|
+
failureKind: RemediationFailureKind,
|
|
279
|
+
failureEvidence: RemediationFailureEvidence,
|
|
280
|
+
planRiskAudit: CurrentPlanRiskAudit,
|
|
281
|
+
now: Date,
|
|
282
|
+
reboundStopOverrideDecisionEventId: string | null = null,
|
|
283
|
+
causeBindings: readonly RemediationCauseBinding[] = [],
|
|
284
|
+
): RemediationEvent {
|
|
285
|
+
const prepared = prepareRemediationEvent(
|
|
286
|
+
store,
|
|
287
|
+
task,
|
|
288
|
+
stepId,
|
|
289
|
+
failureKind,
|
|
290
|
+
failureEvidence,
|
|
291
|
+
planRiskAudit,
|
|
292
|
+
now,
|
|
293
|
+
reboundStopOverrideDecisionEventId,
|
|
294
|
+
causeBindings,
|
|
295
|
+
);
|
|
296
|
+
if (prepared.postimage === null) return prepared.event;
|
|
297
|
+
return store.appendSidecarEvent<RemediationEvent>(
|
|
298
|
+
store.taskRoot(task.projectId, task.id),
|
|
299
|
+
REMEDIATION_EVENTS_SIDECAR,
|
|
300
|
+
prepared.event,
|
|
301
|
+
{
|
|
302
|
+
validateEvent: (event, context) => validateStoredRemediationEvent(event, task, context.file, context.line),
|
|
303
|
+
},
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function prepareRemediationEvent(
|
|
308
|
+
store: FileStateStore,
|
|
309
|
+
task: TaskState,
|
|
310
|
+
stepId: string,
|
|
311
|
+
failureKind: RemediationFailureKind,
|
|
312
|
+
failureEvidence: RemediationFailureEvidence,
|
|
313
|
+
planRiskAudit: CurrentPlanRiskAudit,
|
|
314
|
+
now: Date,
|
|
315
|
+
reboundStopOverrideDecisionEventId: string | null = null,
|
|
316
|
+
causeBindings: readonly RemediationCauseBinding[] = [],
|
|
317
|
+
): PreparedRemediationEvent {
|
|
318
|
+
const recoveredDecisionEventId = findCorrectiveRecoveryDecisionIdForFailure(store, task, stepId);
|
|
319
|
+
const allExisting = readRemediationEvents(store, task);
|
|
320
|
+
const existing = allExisting.filter((event) => event.stepId === stepId);
|
|
321
|
+
const correctiveDecisions = readCorrectiveDecisionEvents(store, task);
|
|
322
|
+
const matching = existing.filter((event) => remediationEventMatchesFailure(event, failureKind, failureEvidence));
|
|
323
|
+
if (matching.length > 1) {
|
|
324
|
+
throw new WorkflowError('STATE_CORRUPT', 'Multiple remediation events match the same failure identity.', {
|
|
325
|
+
taskId: task.id,
|
|
326
|
+
stepId,
|
|
327
|
+
failureKind,
|
|
328
|
+
matchingEventIds: matching.map((event) => event.eventId),
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
if (matching.length === 1) {
|
|
332
|
+
const reused = matching[0]!;
|
|
333
|
+
if (canonicalJsonStringify(reused.failureEvidence) !== canonicalJsonStringify(failureEvidence)) {
|
|
334
|
+
throw new WorkflowError('STATE_CONFLICT', 'Remediation failure evidence changed within the same execution identity.', {
|
|
335
|
+
taskId: task.id,
|
|
336
|
+
stepId,
|
|
337
|
+
eventId: reused.eventId,
|
|
338
|
+
executionTaskRevision: failureEvidence.executionTaskRevision ?? null,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
const priorEvents = existing.filter((event) => event.attemptOrdinal < reused.attemptOrdinal);
|
|
342
|
+
const cycle = selectCurrentPlanRemediationCycle(store, task, priorEvents, correctiveDecisions);
|
|
343
|
+
const gate = deriveAttemptGateFromHistory(store, task, stepId, planRiskAudit, cycle.remediationEvents, cycle.correctiveDecisions, {
|
|
344
|
+
recoveredDecisionEventId,
|
|
345
|
+
reboundStopOverrideDecisionEventId,
|
|
346
|
+
overriddenStopDecisionEventIds: stopEscalateOverrideDecisionEventIds(store, task),
|
|
347
|
+
priorRemediationEvents: cycle.priorRemediationEvents,
|
|
348
|
+
nextAttemptOrdinal: reused.attemptOrdinal,
|
|
349
|
+
});
|
|
350
|
+
if (reused.attemptOrdinal !== gate.nextAttemptOrdinal || reused.mode !== gate.mode || reused.guardedCategory !== gate.guardedCategory) {
|
|
351
|
+
throw new WorkflowError('STATE_CORRUPT', 'Existing remediation event conflicts with the current guarded retry binding.', {
|
|
352
|
+
taskId: task.id,
|
|
353
|
+
stepId,
|
|
354
|
+
eventId: reused.eventId,
|
|
355
|
+
expectedAttemptOrdinal: gate.nextAttemptOrdinal,
|
|
356
|
+
actualAttemptOrdinal: reused.attemptOrdinal,
|
|
357
|
+
expectedMode: gate.mode,
|
|
358
|
+
actualMode: reused.mode,
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
return { event: reused, postimage: null };
|
|
362
|
+
}
|
|
363
|
+
const cycle = selectCurrentPlanRemediationCycle(store, task, existing, correctiveDecisions);
|
|
364
|
+
const gate = deriveAttemptGateFromHistory(store, task, stepId, planRiskAudit, cycle.remediationEvents, cycle.correctiveDecisions, {
|
|
365
|
+
recoveredDecisionEventId,
|
|
366
|
+
reboundStopOverrideDecisionEventId,
|
|
367
|
+
overriddenStopDecisionEventIds: stopEscalateOverrideDecisionEventIds(store, task),
|
|
368
|
+
priorRemediationEvents: cycle.priorRemediationEvents,
|
|
369
|
+
nextAttemptOrdinal: existing.length + 1,
|
|
370
|
+
});
|
|
371
|
+
const prepared = prepareSidecarAppend<RemediationEvent>(
|
|
372
|
+
allExisting,
|
|
373
|
+
{
|
|
374
|
+
eventId: `RME-${createUlid(now.getTime())}`,
|
|
375
|
+
recordedAt: now.toISOString(),
|
|
376
|
+
taskId: task.id,
|
|
377
|
+
stepId,
|
|
378
|
+
guardedCategory: gate.guardedCategory,
|
|
379
|
+
failureKind,
|
|
380
|
+
failureEvidence,
|
|
381
|
+
attemptOrdinal: gate.nextAttemptOrdinal,
|
|
382
|
+
mode: gate.mode,
|
|
383
|
+
causeBindings: causeBindings.map((binding) => ({ ...binding })),
|
|
384
|
+
}, {
|
|
385
|
+
validateEvent: (event, context) => validateStoredRemediationEvent(event, task, context.file, context.line),
|
|
386
|
+
},
|
|
387
|
+
);
|
|
388
|
+
return { event: prepared.event, postimage: prepared.content };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function findCorrectiveRecoveryDecisionIdForFailure(
|
|
392
|
+
store: FileStateStore,
|
|
393
|
+
task: TaskState,
|
|
394
|
+
stepId: string,
|
|
395
|
+
): string | null {
|
|
396
|
+
const remediations = readRawRemediationEvents(store, task).filter((event) => event.stepId === stepId);
|
|
397
|
+
if (remediations.length !== 2) return null;
|
|
398
|
+
const decisions = readRawCorrectiveDecisionEvents(store, task).filter((event) =>
|
|
399
|
+
event.stepId === stepId
|
|
400
|
+
&& event.triggeringAttemptCount === 3
|
|
401
|
+
&& event.decision === 'continue-fix'
|
|
402
|
+
&& event.planHash !== task.planHash,
|
|
403
|
+
);
|
|
404
|
+
if (decisions.length !== 1) return null;
|
|
405
|
+
const recoveries = readRawCorrectiveDecisionRecoveries(store, task).filter((event) =>
|
|
406
|
+
event.stepId === stepId
|
|
407
|
+
&& event.triggeringAttemptCount === 3
|
|
408
|
+
&& event.correctiveDecisionEventId === decisions[0]!.eventId
|
|
409
|
+
&& event.correctiveDecisionEventHash === decisions[0]!.eventHash
|
|
410
|
+
&& event.reboundPlanHash === task.planHash,
|
|
411
|
+
);
|
|
412
|
+
if (recoveries.length > 1) {
|
|
413
|
+
throw new WorkflowError('STATE_CORRUPT', 'Multiple corrective recoveries match one remediation attempt.', {
|
|
414
|
+
taskId: task.id,
|
|
415
|
+
stepId,
|
|
416
|
+
recoveryEventIds: recoveries.map((event) => event.eventId),
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
return recoveries[0]?.correctiveDecisionEventId ?? null;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export function appendCorrectiveDecisionEvent(
|
|
423
|
+
store: FileStateStore,
|
|
424
|
+
task: TaskState,
|
|
425
|
+
stepId: string,
|
|
426
|
+
correctiveAudit: CorrectivePlanAuditInput,
|
|
427
|
+
planRiskAudit: CurrentPlanRiskAudit,
|
|
428
|
+
planHash: string,
|
|
429
|
+
now: Date,
|
|
430
|
+
): CorrectiveDecisionEvent {
|
|
431
|
+
validateCorrectiveDecisionAudit(correctiveAudit);
|
|
432
|
+
const remediationEvents = readRemediationEvents(store, task).filter((event) => event.stepId === stepId);
|
|
433
|
+
const triggeringAttemptCount = remediationEvents.length + 1;
|
|
434
|
+
if (triggeringAttemptCount < 3) {
|
|
435
|
+
throw new WorkflowError(
|
|
436
|
+
'TRANSITION_BLOCKED',
|
|
437
|
+
`Corrective decision is not available for ${stepId} before two guarded remediation failures exist.`,
|
|
438
|
+
{
|
|
439
|
+
taskId: task.id,
|
|
440
|
+
stepId,
|
|
441
|
+
triggeringAttemptCount,
|
|
442
|
+
},
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
const decisions = readCorrectiveDecisionEvents(store, task);
|
|
446
|
+
const matching = decisions.filter((event) =>
|
|
447
|
+
event.taskId === task.id
|
|
448
|
+
&& event.stepId === stepId
|
|
449
|
+
&& event.triggeringAttemptCount === triggeringAttemptCount,
|
|
450
|
+
);
|
|
451
|
+
if (matching.length > 1) {
|
|
452
|
+
throw new WorkflowError(
|
|
453
|
+
'STATE_CORRUPT',
|
|
454
|
+
'Multiple corrective decisions exist for the current guarded remediation binding.',
|
|
455
|
+
{
|
|
456
|
+
taskId: task.id,
|
|
457
|
+
stepId,
|
|
458
|
+
triggeringAttemptCount,
|
|
459
|
+
eventIds: matching.map((event) => event.eventId),
|
|
460
|
+
},
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
const coveredFindingIds = normalizeStringList(correctiveAudit.reviewedFindingIds, 'Corrective decision reviewedFindingIds');
|
|
464
|
+
const coveredEventIds = remediationEvents.map((event) => event.eventId);
|
|
465
|
+
if (matching.length === 1) {
|
|
466
|
+
const reused = matching[0]!;
|
|
467
|
+
if (reused.planHash !== planHash) {
|
|
468
|
+
throw new WorkflowError('STATE_CONFLICT', 'A corrective decision already exists for this Step and triggering ordinal under a different Plan hash.', {
|
|
469
|
+
taskId: task.id,
|
|
470
|
+
stepId,
|
|
471
|
+
triggeringAttemptCount,
|
|
472
|
+
existingPlanHash: reused.planHash,
|
|
473
|
+
requestedPlanHash: planHash,
|
|
474
|
+
eventId: reused.eventId,
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
if (
|
|
478
|
+
reused.decision !== correctiveAudit.decision
|
|
479
|
+
|| reused.auditor !== correctiveAudit.auditor.trim()
|
|
480
|
+
|| !sameStringArray(reused.coveredFindingIds, coveredFindingIds)
|
|
481
|
+
|| !sameStringArray(reused.coveredEventIds, coveredEventIds)
|
|
482
|
+
) {
|
|
483
|
+
throw new WorkflowError('STATE_CONFLICT', 'Existing corrective decision payload does not match the requested guarded remediation binding.', {
|
|
484
|
+
taskId: task.id,
|
|
485
|
+
stepId,
|
|
486
|
+
triggeringAttemptCount,
|
|
487
|
+
eventId: reused.eventId,
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
return reused;
|
|
491
|
+
}
|
|
492
|
+
requireGuardedCategory(planRiskAudit, stepId);
|
|
493
|
+
return store.appendSidecarEvent<CorrectiveDecisionEvent>(
|
|
494
|
+
store.taskRoot(task.projectId, task.id),
|
|
495
|
+
CORRECTIVE_DECISIONS_SIDECAR,
|
|
496
|
+
{
|
|
497
|
+
eventId: `CRD-${createUlid(now.getTime())}`,
|
|
498
|
+
recordedAt: now.toISOString(),
|
|
499
|
+
taskId: task.id,
|
|
500
|
+
stepId,
|
|
501
|
+
triggeringAttemptCount,
|
|
502
|
+
decision: correctiveAudit.decision,
|
|
503
|
+
auditor: correctiveAudit.auditor.trim(),
|
|
504
|
+
coveredFindingIds,
|
|
505
|
+
coveredEventIds,
|
|
506
|
+
planHash,
|
|
507
|
+
},
|
|
508
|
+
{
|
|
509
|
+
validateEvent: (event, context) => validateStoredCorrectiveDecisionEvent(event, task, context.file, context.line),
|
|
510
|
+
},
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
export function appendPlanIntegrityRecoveryDecision(
|
|
516
|
+
store: FileStateStore,
|
|
517
|
+
task: TaskState,
|
|
518
|
+
stepId: string,
|
|
519
|
+
auditor: string,
|
|
520
|
+
evidence: PlanIntegrityConflictEvidence,
|
|
521
|
+
now: Date,
|
|
522
|
+
): CorrectiveDecisionEvent {
|
|
523
|
+
if (!task.planHash) {
|
|
524
|
+
throw new WorkflowError('STATE_CORRUPT', `Task ${task.id} has no Plan hash for Plan-integrity recovery.`);
|
|
525
|
+
}
|
|
526
|
+
if (!auditor.trim()) {
|
|
527
|
+
throw new WorkflowError('INVALID_ARGUMENT', 'Plan-integrity recovery requires a derived auditor identity.');
|
|
528
|
+
}
|
|
529
|
+
const allRemediationEvents = readRemediationEvents(store, task).filter(event => event.stepId === stepId);
|
|
530
|
+
const support = evidence.kind === 'check-support-anchor-outside-allowed-writes' || evidence.kind === 'failed-check-component-owner-outside-allowed-writes';
|
|
531
|
+
const remediationEvents = support ? selectPlanIntegrityRemediations(store, task, allRemediationEvents,
|
|
532
|
+
readCorrectiveDecisionEvents(store, task).filter(event => event.stepId === stepId)) : allRemediationEvents;
|
|
533
|
+
if (!remediationEvents) throw new WorkflowError('TRANSITION_BLOCKED', 'Mechanical recovery lacks a verified current Plan cycle.');
|
|
534
|
+
const expectedBindings = evidence.kind === 'missing-npm-script-outside-allowed-writes'
|
|
535
|
+
? [{
|
|
536
|
+
eventId: evidence.remediationEventId,
|
|
537
|
+
eventHash: evidence.remediationEventHash,
|
|
538
|
+
attemptOrdinal: 1,
|
|
539
|
+
}]
|
|
540
|
+
: evidence.remediationEvents;
|
|
541
|
+
if (remediationEvents.length !== expectedBindings.length
|
|
542
|
+
|| remediationEvents.some((event, index) => {
|
|
543
|
+
const binding = expectedBindings[index];
|
|
544
|
+
return event.failureKind !== 'checks-failed'
|
|
545
|
+
|| event.eventId !== binding?.eventId
|
|
546
|
+
|| event.eventHash !== binding.eventHash
|
|
547
|
+
|| event.attemptOrdinal !== binding.attemptOrdinal;
|
|
548
|
+
})) {
|
|
549
|
+
throw new WorkflowError('TRANSITION_BLOCKED', 'Plan-integrity recovery requires the exact checks-failed remediation history.', {
|
|
550
|
+
taskId: task.id,
|
|
551
|
+
stepId,
|
|
552
|
+
remediationEventIds: remediationEvents.map((event) => event.eventId),
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
const triggeringAttemptCount = allRemediationEvents.length + 1;
|
|
556
|
+
const recoveryDecision = evidence.kind === 'check-support-anchor-outside-allowed-writes'
|
|
557
|
+
|| evidence.kind === 'failed-check-component-owner-outside-allowed-writes'
|
|
558
|
+
? 'continue-fix' as const
|
|
559
|
+
: 'replan-required' as const;
|
|
560
|
+
const decisions = readCorrectiveDecisionEvents(store, task).filter((event) =>
|
|
561
|
+
event.stepId === stepId && event.triggeringAttemptCount === triggeringAttemptCount);
|
|
562
|
+
const findingId = `PLAN-INTEGRITY-${evidence.conflictHash.slice(0, 16).toUpperCase()}`;
|
|
563
|
+
if (decisions.length > 1) {
|
|
564
|
+
throw new WorkflowError('STATE_CORRUPT', 'Multiple Plan-integrity recovery decisions exist for the same Step.', {
|
|
565
|
+
taskId: task.id,
|
|
566
|
+
stepId,
|
|
567
|
+
eventIds: decisions.map((event) => event.eventId),
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
if (decisions.length === 1) {
|
|
571
|
+
const existing = decisions[0]!;
|
|
572
|
+
if (
|
|
573
|
+
existing.planHash !== task.planHash
|
|
574
|
+
|| existing.decision !== recoveryDecision
|
|
575
|
+
|| existing.auditor !== auditor.trim()
|
|
576
|
+
|| existing.planIntegrityEvidence?.conflictHash !== evidence.conflictHash
|
|
577
|
+
) {
|
|
578
|
+
throw new WorkflowError('STATE_CONFLICT', 'Existing Plan-integrity recovery decision does not match the requested conflict.', {
|
|
579
|
+
taskId: task.id,
|
|
580
|
+
stepId,
|
|
581
|
+
eventId: existing.eventId,
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
return existing;
|
|
585
|
+
}
|
|
586
|
+
return store.appendSidecarEvent<CorrectiveDecisionEvent>(
|
|
587
|
+
store.taskRoot(task.projectId, task.id),
|
|
588
|
+
CORRECTIVE_DECISIONS_SIDECAR,
|
|
589
|
+
{
|
|
590
|
+
eventId: `CRD-${createUlid(now.getTime())}`,
|
|
591
|
+
recordedAt: now.toISOString(),
|
|
592
|
+
taskId: task.id,
|
|
593
|
+
stepId,
|
|
594
|
+
triggeringAttemptCount,
|
|
595
|
+
decision: recoveryDecision,
|
|
596
|
+
auditor: auditor.trim(),
|
|
597
|
+
coveredFindingIds: [findingId],
|
|
598
|
+
coveredEventIds: allRemediationEvents.map((event) => event.eventId),
|
|
599
|
+
planHash: task.planHash,
|
|
600
|
+
recoveryBasis: 'plan-integrity',
|
|
601
|
+
planIntegrityEvidence: evidence,
|
|
602
|
+
},
|
|
603
|
+
{
|
|
604
|
+
validateEvent: (event, context) => validateStoredCorrectiveDecisionEvent(event, task, context.file, context.line),
|
|
605
|
+
},
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
export function assertGuardedRemediationAttemptAllowed(
|
|
610
|
+
store: FileStateStore,
|
|
611
|
+
task: TaskState,
|
|
612
|
+
stepId: string,
|
|
613
|
+
planRiskAudit: CurrentPlanRiskAudit,
|
|
614
|
+
recoveredDecisionEventId: string | null = null,
|
|
615
|
+
reboundStopOverrideDecisionEventId: string | null = null,
|
|
616
|
+
): GuardedRemediationAttemptGate {
|
|
617
|
+
const history = readOpenGuardedRemediationHistory(store, task, stepId);
|
|
618
|
+
return deriveAttemptGateFromHistory(store, task, stepId, planRiskAudit, history.remediationEvents, history.correctiveDecisions, {
|
|
619
|
+
enforceRuntimeGate: true,
|
|
620
|
+
recoveredDecisionEventId,
|
|
621
|
+
reboundStopOverrideDecisionEventId,
|
|
622
|
+
overriddenStopDecisionEventIds: stopEscalateOverrideDecisionEventIds(store, task),
|
|
623
|
+
priorRemediationEvents: history.priorRemediationEvents,
|
|
624
|
+
nextAttemptOrdinal: history.nextAttemptOrdinal,
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
export function readGuardedRemediationPostureForStep(
|
|
629
|
+
store: FileStateStore,
|
|
630
|
+
task: TaskState,
|
|
631
|
+
stepId: string,
|
|
632
|
+
planRiskAudit: CurrentPlanRiskAudit,
|
|
633
|
+
): GuardedRemediationAttemptGate | null {
|
|
634
|
+
if (!planRiskAudit.reviewRequiredStepIds.includes(stepId)) return null;
|
|
635
|
+
const step = task.steps.find((candidate) => candidate.id === stepId) ?? null;
|
|
636
|
+
if (!step) {
|
|
637
|
+
throw new WorkflowError('STATE_CORRUPT', `Task ${task.id} is missing guarded Step ${stepId}.`, {
|
|
638
|
+
taskId: task.id,
|
|
639
|
+
stepId,
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
const history = readOpenGuardedRemediationHistory(store, task, stepId);
|
|
643
|
+
const remediationEvents = history.remediationEvents;
|
|
644
|
+
const earlyPlanIntegrityDecision = remediationEvents.length === 1
|
|
645
|
+
? findCurrentCorrectiveDecision(task, stepId, 2, history.correctiveDecisions)
|
|
646
|
+
: null;
|
|
647
|
+
const currentDecision = findCurrentCorrectiveDecision(task, stepId, history.nextAttemptOrdinal, history.correctiveDecisions);
|
|
648
|
+
if (remediationEvents.length < 2 && earlyPlanIntegrityDecision?.recoveryBasis !== 'plan-integrity'
|
|
649
|
+
&& currentDecision === null
|
|
650
|
+
&& deriveCurrentCauseDecision(remediationEvents, history.priorRemediationEvents) !== 'root-cause-replan') return null;
|
|
651
|
+
if (step.status === 'failed' || step.status === 'planned') {
|
|
652
|
+
return deriveAttemptGateFromHistory(store, task, stepId, planRiskAudit, remediationEvents, history.correctiveDecisions, {
|
|
653
|
+
overriddenStopDecisionEventIds: stopEscalateOverrideDecisionEventIds(store, task),
|
|
654
|
+
priorRemediationEvents: history.priorRemediationEvents,
|
|
655
|
+
nextAttemptOrdinal: history.nextAttemptOrdinal,
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
if (task.status !== 'blocked' || step.status !== 'in_progress' || !step.evidence) return null;
|
|
659
|
+
const currentCycle = buildCurrentStrictStepReviewCycle(store, task, stepId);
|
|
660
|
+
if (currentCycle.resolution !== 'unverified') return null;
|
|
661
|
+
return deriveAttemptGateFromHistory(store, task, stepId, planRiskAudit, remediationEvents, history.correctiveDecisions, {
|
|
662
|
+
overriddenStopDecisionEventIds: stopEscalateOverrideDecisionEventIds(store, task),
|
|
663
|
+
priorRemediationEvents: history.priorRemediationEvents,
|
|
664
|
+
nextAttemptOrdinal: history.nextAttemptOrdinal,
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
interface GuardedRemediationHistory {
|
|
669
|
+
remediationEvents: RemediationEvent[];
|
|
670
|
+
priorRemediationEvents: RemediationEvent[];
|
|
671
|
+
correctiveDecisions: CorrectiveDecisionEvent[];
|
|
672
|
+
nextAttemptOrdinal: number;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
interface RemediationPlanCycleBinding {
|
|
676
|
+
semanticHash: string;
|
|
677
|
+
planHash: string;
|
|
678
|
+
sourceTaskRevision: number;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function readOpenGuardedRemediationHistory(
|
|
682
|
+
store: FileStateStore,
|
|
683
|
+
task: TaskState,
|
|
684
|
+
stepId: string,
|
|
685
|
+
): GuardedRemediationHistory {
|
|
686
|
+
const allStepRemediations = readRemediationEvents(store, task).filter((event) => event.stepId === stepId);
|
|
687
|
+
const allStepDecisions = readCorrectiveDecisionEvents(store, task).filter((event) => event.stepId === stepId);
|
|
688
|
+
const passedBoundary = latestPassedVerifiedStepReviewEvent(store, task, stepId);
|
|
689
|
+
const remediationEvents = passedBoundary
|
|
690
|
+
? allStepRemediations.filter((event) => event.recordedAt > passedBoundary.recordedAt)
|
|
691
|
+
: allStepRemediations;
|
|
692
|
+
const correctiveDecisions = passedBoundary
|
|
693
|
+
? allStepDecisions.filter((event) => event.recordedAt > passedBoundary.recordedAt)
|
|
694
|
+
: allStepDecisions;
|
|
695
|
+
return {
|
|
696
|
+
...selectCurrentPlanRemediationCycle(store, task, remediationEvents, correctiveDecisions),
|
|
697
|
+
nextAttemptOrdinal: allStepRemediations.length + 1,
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function selectCurrentPlanRemediationCycle(
|
|
702
|
+
store: FileStateStore,
|
|
703
|
+
task: TaskState,
|
|
704
|
+
remediationEvents: readonly RemediationEvent[],
|
|
705
|
+
correctiveDecisions: readonly CorrectiveDecisionEvent[],
|
|
706
|
+
canonicalPlanHash = task.planHash,
|
|
707
|
+
): Omit<GuardedRemediationHistory, 'nextAttemptOrdinal'> {
|
|
708
|
+
const unchanged = () => ({
|
|
709
|
+
remediationEvents: [...remediationEvents],
|
|
710
|
+
priorRemediationEvents: [],
|
|
711
|
+
correctiveDecisions: [...correctiveDecisions],
|
|
712
|
+
});
|
|
713
|
+
if (remediationEvents.length === 0 || !task.planHash) return unchanged();
|
|
714
|
+
const audits = readPlanRiskAuditEvents(store, task);
|
|
715
|
+
const currentAudit = [...audits].reverse().find((event) =>
|
|
716
|
+
event.planHash === task.planHash
|
|
717
|
+
&& event.taskRevision <= task.revision
|
|
718
|
+
&& event.decision !== 'split-required'
|
|
719
|
+
&& event.decision !== 'stop-escalate') ?? null;
|
|
720
|
+
if (!currentAudit) return unchanged();
|
|
721
|
+
const taskRoot = store.taskRoot(task.projectId, task.id);
|
|
722
|
+
let currentSemanticHash: string;
|
|
723
|
+
try {
|
|
724
|
+
currentSemanticHash = normalizePlanKnowledgeBinding(
|
|
725
|
+
readVerifiedPlanArtifact(taskRoot, task.planHash, canonicalPlanHash!),
|
|
726
|
+
'current',
|
|
727
|
+
).semanticHash;
|
|
728
|
+
} catch {
|
|
729
|
+
return unchanged();
|
|
730
|
+
}
|
|
731
|
+
const reviews = readStepReviewEvents(store, task);
|
|
732
|
+
const bindings = remediationEvents.map((event) =>
|
|
733
|
+
resolveRemediationPlanCycleBinding(taskRoot, canonicalPlanHash!, event, reviews, audits));
|
|
734
|
+
if (bindings.some((binding) => binding === null)) return unchanged();
|
|
735
|
+
let cycleStart = remediationEvents.length;
|
|
736
|
+
while (cycleStart > 0 && bindings[cycleStart - 1]!.semanticHash === currentSemanticHash) cycleStart -= 1;
|
|
737
|
+
if (cycleStart === remediationEvents.length
|
|
738
|
+
&& currentAudit.taskRevision <= bindings.at(-1)!.sourceTaskRevision) return unchanged();
|
|
739
|
+
return {
|
|
740
|
+
remediationEvents: remediationEvents.slice(cycleStart),
|
|
741
|
+
priorRemediationEvents: remediationEvents.slice(0, cycleStart),
|
|
742
|
+
// Corrective decisions remain globally authoritative. The semantic cycle scopes
|
|
743
|
+
// cause counting only; filtering decisions could discard stop/split authority.
|
|
744
|
+
correctiveDecisions: [...correctiveDecisions],
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/** Scope mechanical support evidence only; global corrective authority is never discarded. */
|
|
749
|
+
export function selectPlanIntegrityRemediations(
|
|
750
|
+
store: FileStateStore, task: TaskState, events: readonly RemediationEvent[], decisions: readonly CorrectiveDecisionEvent[],
|
|
751
|
+
canonicalPlanHash = task.planHash,
|
|
752
|
+
): RemediationEvent[] | null {
|
|
753
|
+
if (events.length === 0) return null;
|
|
754
|
+
const stepId = events[0]!.stepId;
|
|
755
|
+
if (events.some(event => event.stepId !== stepId) || decisions.some(event => event.stepId !== stepId)) return null;
|
|
756
|
+
const cycle = selectCurrentPlanRemediationCycle(store, task, events, decisions, canonicalPlanHash);
|
|
757
|
+
if (cycle.priorRemediationEvents.length === 0) return decisions.length === 0 ? [...events] : null;
|
|
758
|
+
if (!task.planHash || cycle.remediationEvents.length === 0
|
|
759
|
+
|| decisions.some(d => d.decision === 'stop-escalate' || d.decision === 'split-required')) return null;
|
|
760
|
+
const root = store.taskRoot(task.projectId, task.id);
|
|
761
|
+
const history = readCompletedCorrectiveReplanHistory(store, task);
|
|
762
|
+
const semantic = (hash: string) => {
|
|
763
|
+
const saved = history.flatMap(boundary => [boundary.beforePlan, boundary.afterPlan]).find(bytes => sha256Hex(bytes) === hash);
|
|
764
|
+
return normalizePlanKnowledgeBinding(saved ?? readVerifiedPlanArtifact(root, hash, canonicalPlanHash!), 'cycle').semanticHash;
|
|
765
|
+
};
|
|
766
|
+
const currentSemantic = semantic(task.planHash);
|
|
767
|
+
const audits = readPlanRiskAuditEvents(store, task), reviews = readStepReviewEvents(store, task);
|
|
768
|
+
const priorBindings = cycle.priorRemediationEvents.map(event => resolveRemediationPlanCycleBinding(root, canonicalPlanHash!, event, reviews, audits));
|
|
769
|
+
const currentBindings = cycle.remediationEvents.map(event => resolveRemediationPlanCycleBinding(root, canonicalPlanHash!, event, reviews, audits));
|
|
770
|
+
if (priorBindings.some(b => !b) || currentBindings.some(b => !b)) return null;
|
|
771
|
+
const boundaries = history.filter(boundary =>
|
|
772
|
+
correctiveReplanBoundaryMatchesStep(boundary.before, stepId)
|
|
773
|
+
&& boundary.after.planHash && boundary.before.planHash
|
|
774
|
+
&& semantic(boundary.after.planHash) === currentSemantic && semantic(boundary.before.planHash) !== currentSemantic
|
|
775
|
+
&& boundary.after.revision <= task.revision
|
|
776
|
+
&& priorBindings.every(b => b!.sourceTaskRevision < boundary.after.revision)
|
|
777
|
+
&& currentBindings.every(b => b!.sourceTaskRevision > boundary.after.revision));
|
|
778
|
+
if (boundaries.length !== 1) return null;
|
|
779
|
+
const boundary = boundaries[0]!;
|
|
780
|
+
let cursor = task.planHash;
|
|
781
|
+
const visited = new Set<string>();
|
|
782
|
+
const planBytes = (hash: string) => history.flatMap(b => [b.beforePlan, b.afterPlan]).find(bytes => sha256Hex(bytes) === hash)
|
|
783
|
+
?? readVerifiedPlanArtifact(root, hash, canonicalPlanHash!);
|
|
784
|
+
while (cursor !== boundary.after.planHash) {
|
|
785
|
+
if (visited.has(cursor)) return null;
|
|
786
|
+
visited.add(cursor);
|
|
787
|
+
const links = task.knowledgeRebinds.filter(link => link.reboundPlanHash === cursor);
|
|
788
|
+
if (links.length !== 1 || !links[0]!.previousPlanHash) return null;
|
|
789
|
+
const link = links[0]!;
|
|
790
|
+
assertKnowledgeBindingOnlyPlanChange(planBytes(link.previousPlanHash!), planBytes(cursor));
|
|
791
|
+
if (!planBytes(link.previousPlanHash!).includes(`- Knowledge map: revision ${link.fromKnowledgeMapRevision}, hash ${link.fromKnowledgeMapHash}\n`)
|
|
792
|
+
|| !planBytes(cursor).includes(`- Knowledge map: revision ${link.toKnowledgeMapRevision}, hash ${link.toKnowledgeMapHash}\n`)) return null;
|
|
793
|
+
cursor = link.previousPlanHash!;
|
|
794
|
+
}
|
|
795
|
+
if (!task.authorizations.some(a => a.kind === 'execution' && ['approved', 'superseded'].includes(a.decision)
|
|
796
|
+
&& a.planHash === task.planHash && a.briefHash === task.briefHash
|
|
797
|
+
&& a.recordedAt >= boundary.admittedAt)) return null;
|
|
798
|
+
if (decisions.length === 0 || decisions.some(d => d.recordedAt > boundary.admittedAt
|
|
799
|
+
|| semantic(d.planHash) === currentSemantic)) return null;
|
|
800
|
+
const last = decisions.at(-1)!;
|
|
801
|
+
if (last.decision !== 'replan-required' || last.triggeringAttemptCount !== cycle.priorRemediationEvents.length + 1
|
|
802
|
+
|| semantic(last.planHash) !== semantic(boundary.before.planHash!)) return null;
|
|
803
|
+
return cycle.remediationEvents;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function resolveRemediationPlanCycleBinding(
|
|
807
|
+
taskRoot: string,
|
|
808
|
+
currentPlanHash: string,
|
|
809
|
+
event: RemediationEvent,
|
|
810
|
+
reviews: readonly import('../contracts.js').StepReviewEvent[],
|
|
811
|
+
audits: readonly PlanRiskAuditEvent[],
|
|
812
|
+
): RemediationPlanCycleBinding | null {
|
|
813
|
+
let planHash: string | null = null;
|
|
814
|
+
let sourceTaskRevision: number | null = null;
|
|
815
|
+
if (event.failureEvidence.reviewEventHash) {
|
|
816
|
+
const review = reviews.find((candidate) => candidate.eventHash === event.failureEvidence.reviewEventHash) ?? null;
|
|
817
|
+
if (!review) return null;
|
|
818
|
+
planHash = review.planHash;
|
|
819
|
+
sourceTaskRevision = review.taskRevision;
|
|
820
|
+
} else if (event.failureEvidence.executionTaskRevision !== undefined) {
|
|
821
|
+
sourceTaskRevision = event.failureEvidence.executionTaskRevision;
|
|
822
|
+
planHash = [...audits].reverse().find((audit) => audit.taskRevision <= sourceTaskRevision!)?.planHash ?? null;
|
|
823
|
+
}
|
|
824
|
+
if (!planHash || sourceTaskRevision === null
|
|
825
|
+
|| !audits.some((audit) => audit.planHash === planHash && audit.taskRevision <= sourceTaskRevision)) return null;
|
|
826
|
+
try {
|
|
827
|
+
return {
|
|
828
|
+
semanticHash: normalizePlanKnowledgeBinding(
|
|
829
|
+
readVerifiedPlanArtifact(taskRoot, planHash, currentPlanHash),
|
|
830
|
+
'remediation ' + event.eventId,
|
|
831
|
+
).semanticHash,
|
|
832
|
+
planHash,
|
|
833
|
+
sourceTaskRevision,
|
|
834
|
+
};
|
|
835
|
+
} catch {
|
|
836
|
+
return null;
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
export function deriveCurrentCauseDecisionForTask(
|
|
841
|
+
store: FileStateStore,
|
|
842
|
+
task: TaskState,
|
|
843
|
+
stepId: string,
|
|
844
|
+
remediationEvents: readonly RemediationEvent[],
|
|
845
|
+
): 'same-task-fix' | 'root-cause-replan' | null {
|
|
846
|
+
const decisions = readCorrectiveDecisionEvents(store, task).filter((event) => event.stepId === stepId);
|
|
847
|
+
const cycle = selectCurrentPlanRemediationCycle(store, task, remediationEvents, decisions);
|
|
848
|
+
return deriveCurrentCauseDecision(cycle.remediationEvents, cycle.priorRemediationEvents);
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
export function findCurrentGuardedRemediationPosture(
|
|
852
|
+
store: FileStateStore,
|
|
853
|
+
task: TaskState,
|
|
854
|
+
planRiskAudit: CurrentPlanRiskAudit,
|
|
855
|
+
): GuardedRemediationAttemptGate | null {
|
|
856
|
+
const matches = planRiskAudit.reviewRequiredStepIds
|
|
857
|
+
.map((stepId) => readGuardedRemediationPostureForStep(store, task, stepId, planRiskAudit))
|
|
858
|
+
.filter((value): value is GuardedRemediationAttemptGate => value !== null);
|
|
859
|
+
if (matches.length === 0) return null;
|
|
860
|
+
if (matches.length > 1) {
|
|
861
|
+
throw new WorkflowError(
|
|
862
|
+
'STATE_CORRUPT',
|
|
863
|
+
'Multiple guarded failed Steps simultaneously require corrective remediation decisions.',
|
|
864
|
+
{
|
|
865
|
+
taskId: task.id,
|
|
866
|
+
stepIds: matches.map((match) => match.stepId),
|
|
867
|
+
},
|
|
868
|
+
);
|
|
869
|
+
}
|
|
870
|
+
return matches[0]!;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
export function findFailedGuardedStepRequiringCorrectiveDecision(
|
|
874
|
+
store: FileStateStore,
|
|
875
|
+
task: TaskState,
|
|
876
|
+
planRiskAudit: CurrentPlanRiskAudit,
|
|
877
|
+
): GuardedRemediationAttemptGate | null {
|
|
878
|
+
const posture = findCurrentGuardedRemediationPosture(store, task, planRiskAudit);
|
|
879
|
+
if (!posture) return null;
|
|
880
|
+
return posture.posture === 'decision-required'
|
|
881
|
+
? posture
|
|
882
|
+
: null;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
function deriveAttemptGateFromHistory(
|
|
886
|
+
store: FileStateStore,
|
|
887
|
+
task: TaskState,
|
|
888
|
+
stepId: string,
|
|
889
|
+
planRiskAudit: CurrentPlanRiskAudit,
|
|
890
|
+
remediationEvents: readonly RemediationEvent[],
|
|
891
|
+
correctiveDecisions: readonly CorrectiveDecisionEvent[],
|
|
892
|
+
options: {
|
|
893
|
+
enforceRuntimeGate?: boolean;
|
|
894
|
+
recoveredDecisionEventId?: string | null;
|
|
895
|
+
reboundStopOverrideDecisionEventId?: string | null;
|
|
896
|
+
overriddenStopDecisionEventIds?: ReadonlySet<string>;
|
|
897
|
+
priorRemediationEvents?: readonly RemediationEvent[];
|
|
898
|
+
nextAttemptOrdinal?: number;
|
|
899
|
+
} = {},
|
|
900
|
+
): GuardedRemediationAttemptGate {
|
|
901
|
+
const attemptCount = remediationEvents.length + (options.priorRemediationEvents?.length ?? 0);
|
|
902
|
+
const nextAttemptOrdinal = options.nextAttemptOrdinal ?? attemptCount + 1;
|
|
903
|
+
const guardedCategory = requireGuardedCategory(planRiskAudit, stepId);
|
|
904
|
+
const rollingCauseDecision = deriveCurrentCauseDecision(remediationEvents, options.priorRemediationEvents ?? []);
|
|
905
|
+
const currentCorrectiveDecision = findCurrentCorrectiveDecision(
|
|
906
|
+
task,
|
|
907
|
+
stepId,
|
|
908
|
+
nextAttemptOrdinal,
|
|
909
|
+
correctiveDecisions,
|
|
910
|
+
);
|
|
911
|
+
const planIntegrityDecision = currentCorrectiveDecision;
|
|
912
|
+
if (planIntegrityDecision?.recoveryBasis === 'plan-integrity'
|
|
913
|
+
&& (planIntegrityDecision.planHash === task.planHash || planIntegrityDecision.decision === 'replan-required'
|
|
914
|
+
|| isKnowledgeReboundSupportDecision(store, task, planIntegrityDecision))) {
|
|
915
|
+
if (planIntegrityDecision.planHash !== task.planHash && planIntegrityDecision.decision === 'replan-required') {
|
|
916
|
+
return {
|
|
917
|
+
stepId,
|
|
918
|
+
guardedCategory,
|
|
919
|
+
attemptCount,
|
|
920
|
+
nextAttemptOrdinal,
|
|
921
|
+
mode: 'corrective',
|
|
922
|
+
correctiveDecision: planIntegrityDecision,
|
|
923
|
+
posture: planIntegrityDecision.decision === 'replan-required' ? 'replan-ready' : 'continue-fix',
|
|
924
|
+
};
|
|
925
|
+
}
|
|
926
|
+
if (planIntegrityDecision.decision === 'continue-fix'
|
|
927
|
+
&& (planIntegrityDecision.planIntegrityEvidence?.kind === 'check-support-anchor-outside-allowed-writes'
|
|
928
|
+
|| planIntegrityDecision.planIntegrityEvidence?.kind === 'failed-check-component-owner-outside-allowed-writes')) {
|
|
929
|
+
return {
|
|
930
|
+
stepId,
|
|
931
|
+
guardedCategory,
|
|
932
|
+
attemptCount,
|
|
933
|
+
nextAttemptOrdinal,
|
|
934
|
+
mode: 'ordinary',
|
|
935
|
+
correctiveDecision: planIntegrityDecision,
|
|
936
|
+
posture: 'continue-fix',
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
if (planIntegrityDecision.decision !== 'replan-required') {
|
|
940
|
+
throw new WorkflowError('STATE_CORRUPT', 'Plan-integrity decision has an unsupported disposition.', {
|
|
941
|
+
taskId: task.id,
|
|
942
|
+
stepId,
|
|
943
|
+
decisionEventId: planIntegrityDecision.eventId,
|
|
944
|
+
});
|
|
945
|
+
}
|
|
946
|
+
return {
|
|
947
|
+
stepId,
|
|
948
|
+
guardedCategory,
|
|
949
|
+
attemptCount,
|
|
950
|
+
nextAttemptOrdinal,
|
|
951
|
+
mode: 'ordinary',
|
|
952
|
+
correctiveDecision: planIntegrityDecision,
|
|
953
|
+
posture: 'replan-required',
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
// A recorded corrective decision remains authoritative over an ordinary
|
|
957
|
+
// rolling continuation. A repeated authoritative cause is fresh evidence
|
|
958
|
+
// that the current Plan itself is no longer viable and still forces replan.
|
|
959
|
+
if (rollingCauseDecision !== null
|
|
960
|
+
&& !(rollingCauseDecision === 'same-task-fix' && currentCorrectiveDecision)) {
|
|
961
|
+
if (rollingCauseDecision === 'root-cause-replan' && options.enforceRuntimeGate) {
|
|
962
|
+
throw new WorkflowError(
|
|
963
|
+
'TRANSITION_BLOCKED',
|
|
964
|
+
`Guarded Step ${stepId} has an explicit Plan conflict or repeated authoritative cause and requires replan.`,
|
|
965
|
+
{ taskId: task.id, stepId, attemptCount, requiredAction: 'task plan-set' },
|
|
966
|
+
);
|
|
967
|
+
}
|
|
968
|
+
return {
|
|
969
|
+
stepId,
|
|
970
|
+
guardedCategory,
|
|
971
|
+
attemptCount,
|
|
972
|
+
nextAttemptOrdinal,
|
|
973
|
+
mode: 'ordinary',
|
|
974
|
+
correctiveDecision: null,
|
|
975
|
+
posture: rollingCauseDecision === 'root-cause-replan' ? 'replan-required' : 'ordinary',
|
|
976
|
+
};
|
|
977
|
+
}
|
|
978
|
+
if (attemptCount < 2) {
|
|
979
|
+
return {
|
|
980
|
+
stepId,
|
|
981
|
+
guardedCategory,
|
|
982
|
+
attemptCount,
|
|
983
|
+
nextAttemptOrdinal,
|
|
984
|
+
mode: 'ordinary',
|
|
985
|
+
correctiveDecision: null,
|
|
986
|
+
posture: 'ordinary',
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
const correctiveDecision = currentCorrectiveDecision;
|
|
990
|
+
if (!correctiveDecision) {
|
|
991
|
+
return {
|
|
992
|
+
stepId,
|
|
993
|
+
guardedCategory,
|
|
994
|
+
attemptCount,
|
|
995
|
+
nextAttemptOrdinal,
|
|
996
|
+
mode: 'corrective',
|
|
997
|
+
correctiveDecision: null,
|
|
998
|
+
posture: 'continue-fix',
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
if (correctiveDecision.planHash !== task.planHash) {
|
|
1002
|
+
if (
|
|
1003
|
+
correctiveDecision.decision === 'continue-fix'
|
|
1004
|
+
&& options.recoveredDecisionEventId === correctiveDecision.eventId
|
|
1005
|
+
) {
|
|
1006
|
+
return {
|
|
1007
|
+
stepId,
|
|
1008
|
+
guardedCategory,
|
|
1009
|
+
attemptCount,
|
|
1010
|
+
nextAttemptOrdinal,
|
|
1011
|
+
mode: 'corrective',
|
|
1012
|
+
correctiveDecision,
|
|
1013
|
+
posture: 'continue-fix',
|
|
1014
|
+
};
|
|
1015
|
+
}
|
|
1016
|
+
if (
|
|
1017
|
+
correctiveDecision.decision === 'stop-escalate'
|
|
1018
|
+
&& options.reboundStopOverrideDecisionEventId === correctiveDecision.eventId
|
|
1019
|
+
&& options.overriddenStopDecisionEventIds?.has(correctiveDecision.eventId)
|
|
1020
|
+
) {
|
|
1021
|
+
return {
|
|
1022
|
+
stepId,
|
|
1023
|
+
guardedCategory,
|
|
1024
|
+
attemptCount,
|
|
1025
|
+
nextAttemptOrdinal,
|
|
1026
|
+
mode: 'corrective',
|
|
1027
|
+
correctiveDecision,
|
|
1028
|
+
posture: 'continue-fix',
|
|
1029
|
+
};
|
|
1030
|
+
}
|
|
1031
|
+
if (correctiveDecision.decision === 'replan-required') {
|
|
1032
|
+
return {
|
|
1033
|
+
stepId,
|
|
1034
|
+
guardedCategory,
|
|
1035
|
+
attemptCount,
|
|
1036
|
+
nextAttemptOrdinal,
|
|
1037
|
+
mode: 'corrective',
|
|
1038
|
+
correctiveDecision,
|
|
1039
|
+
posture: 'replan-ready',
|
|
1040
|
+
};
|
|
1041
|
+
}
|
|
1042
|
+
if (options.enforceRuntimeGate) {
|
|
1043
|
+
throw new WorkflowError(
|
|
1044
|
+
'TRANSITION_BLOCKED',
|
|
1045
|
+
`Guarded Step ${stepId} has a corrective decision for a different Plan hash and cannot continue under the current Plan.`,
|
|
1046
|
+
{
|
|
1047
|
+
taskId: task.id,
|
|
1048
|
+
stepId,
|
|
1049
|
+
triggeringAttemptCount: nextAttemptOrdinal,
|
|
1050
|
+
currentPlanHash: task.planHash,
|
|
1051
|
+
decisionPlanHash: correctiveDecision.planHash,
|
|
1052
|
+
decisionEventId: correctiveDecision.eventId,
|
|
1053
|
+
},
|
|
1054
|
+
);
|
|
1055
|
+
}
|
|
1056
|
+
return {
|
|
1057
|
+
stepId,
|
|
1058
|
+
guardedCategory,
|
|
1059
|
+
attemptCount,
|
|
1060
|
+
nextAttemptOrdinal,
|
|
1061
|
+
mode: 'ordinary',
|
|
1062
|
+
correctiveDecision,
|
|
1063
|
+
posture: 'decision-required',
|
|
1064
|
+
};
|
|
1065
|
+
}
|
|
1066
|
+
if (
|
|
1067
|
+
correctiveDecision.decision === 'stop-escalate'
|
|
1068
|
+
&& options.overriddenStopDecisionEventIds?.has(correctiveDecision.eventId)
|
|
1069
|
+
) {
|
|
1070
|
+
return {
|
|
1071
|
+
stepId,
|
|
1072
|
+
guardedCategory,
|
|
1073
|
+
attemptCount,
|
|
1074
|
+
nextAttemptOrdinal,
|
|
1075
|
+
mode: 'corrective',
|
|
1076
|
+
correctiveDecision,
|
|
1077
|
+
posture: 'continue-fix',
|
|
1078
|
+
};
|
|
1079
|
+
}
|
|
1080
|
+
if (correctiveDecision.decision !== 'continue-fix' && options.enforceRuntimeGate) {
|
|
1081
|
+
throw correctiveDecisionBlockedError(task, stepId, attemptCount, correctiveDecision);
|
|
1082
|
+
}
|
|
1083
|
+
return {
|
|
1084
|
+
stepId,
|
|
1085
|
+
guardedCategory,
|
|
1086
|
+
attemptCount,
|
|
1087
|
+
nextAttemptOrdinal,
|
|
1088
|
+
mode: correctiveDecision.decision === 'continue-fix' ? 'corrective' : 'ordinary',
|
|
1089
|
+
correctiveDecision,
|
|
1090
|
+
posture: correctiveDecision.decision === 'continue-fix' ? 'continue-fix' : correctiveDecision.decision,
|
|
1091
|
+
};
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
// Current execution must respect a proved Plan obstruction immediately. Keep the
|
|
1095
|
+
// historical rolling classifier unchanged: older valid histories may have been
|
|
1096
|
+
// recorded before this gate existed and must not be reclassified as corrupt.
|
|
1097
|
+
export function deriveCurrentCauseDecision(
|
|
1098
|
+
remediationEvents: readonly RemediationEvent[],
|
|
1099
|
+
priorRemediationEvents: readonly RemediationEvent[] = [],
|
|
1100
|
+
): 'same-task-fix' | 'root-cause-replan' | null {
|
|
1101
|
+
// A verified replacement clears the active cause, not the global attempt
|
|
1102
|
+
// count or an explicit corrective decision's dirty-work continuation authority.
|
|
1103
|
+
if (remediationEvents.length === 0 && priorRemediationEvents.length > 0
|
|
1104
|
+
&& priorRemediationEvents.every((event) => event.causeBindings !== undefined)) return 'same-task-fix';
|
|
1105
|
+
if (remediationEvents.at(-1)?.causeBindings?.some(cause =>
|
|
1106
|
+
cause.authority === 'external-reviewer' && cause.planConflict)) return 'root-cause-replan';
|
|
1107
|
+
return deriveRollingCauseDecision(remediationEvents, priorRemediationEvents);
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
export function deriveRollingCauseDecision(
|
|
1111
|
+
remediationEvents: readonly RemediationEvent[],
|
|
1112
|
+
priorRemediationEvents: readonly RemediationEvent[] = [],
|
|
1113
|
+
): 'same-task-fix' | 'root-cause-replan' | null {
|
|
1114
|
+
if (remediationEvents.length === 0 || remediationEvents.some((event) => event.causeBindings === undefined)) return null;
|
|
1115
|
+
const latest = remediationEvents.at(-1)!;
|
|
1116
|
+
const priorFingerprints = new Set([...priorRemediationEvents, ...remediationEvents.slice(0, -1)]
|
|
1117
|
+
.flatMap((event) => event.causeBindings?.map((cause) => cause.causeFingerprint) ?? []));
|
|
1118
|
+
const authoritativeRepeat = latest.causeBindings!.some((cause) =>
|
|
1119
|
+
priorFingerprints.has(cause.causeFingerprint)
|
|
1120
|
+
&& (cause.authority === 'mechanical' || (cause.authority === 'external-reviewer' && cause.planConflict)));
|
|
1121
|
+
return authoritativeRepeat ? 'root-cause-replan' : 'same-task-fix';
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
function findCurrentCorrectiveDecision(
|
|
1125
|
+
task: TaskState,
|
|
1126
|
+
stepId: string,
|
|
1127
|
+
triggeringAttemptOrdinal: number,
|
|
1128
|
+
events: readonly CorrectiveDecisionEvent[],
|
|
1129
|
+
): CorrectiveDecisionEvent | null {
|
|
1130
|
+
const matches = events.filter((event) =>
|
|
1131
|
+
event.taskId === task.id
|
|
1132
|
+
&& event.stepId === stepId
|
|
1133
|
+
&& event.triggeringAttemptCount === triggeringAttemptOrdinal,
|
|
1134
|
+
);
|
|
1135
|
+
if (matches.length === 0) return null;
|
|
1136
|
+
if (matches.length > 1) {
|
|
1137
|
+
throw new WorkflowError(
|
|
1138
|
+
'STATE_CORRUPT',
|
|
1139
|
+
'Multiple corrective decisions exist for the same guarded remediation binding.',
|
|
1140
|
+
{
|
|
1141
|
+
taskId: task.id,
|
|
1142
|
+
stepId,
|
|
1143
|
+
triggeringAttemptCount: triggeringAttemptOrdinal,
|
|
1144
|
+
eventIds: matches.map((event) => event.eventId),
|
|
1145
|
+
},
|
|
1146
|
+
);
|
|
1147
|
+
}
|
|
1148
|
+
return matches[0]!;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
function correctiveDecisionBlockedError(
|
|
1152
|
+
task: TaskState,
|
|
1153
|
+
stepId: string,
|
|
1154
|
+
attemptCount: number,
|
|
1155
|
+
event: CorrectiveDecisionEvent,
|
|
1156
|
+
): WorkflowError {
|
|
1157
|
+
if (event.decision === 'replan-required') {
|
|
1158
|
+
return new WorkflowError(
|
|
1159
|
+
'TRANSITION_BLOCKED',
|
|
1160
|
+
`Guarded Step ${stepId} cannot continue because the corrective decision requires a replan.`,
|
|
1161
|
+
{
|
|
1162
|
+
taskId: task.id,
|
|
1163
|
+
stepId,
|
|
1164
|
+
attemptCount,
|
|
1165
|
+
decision: event.decision,
|
|
1166
|
+
decisionEventId: event.eventId,
|
|
1167
|
+
auditor: event.auditor,
|
|
1168
|
+
},
|
|
1169
|
+
);
|
|
1170
|
+
}
|
|
1171
|
+
if (event.decision === 'split-required') {
|
|
1172
|
+
return new WorkflowError(
|
|
1173
|
+
'TRANSITION_BLOCKED',
|
|
1174
|
+
`Guarded Step ${stepId} cannot continue because the corrective decision requires splitting work into a separate Task.`,
|
|
1175
|
+
{
|
|
1176
|
+
taskId: task.id,
|
|
1177
|
+
stepId,
|
|
1178
|
+
attemptCount,
|
|
1179
|
+
decision: event.decision,
|
|
1180
|
+
decisionEventId: event.eventId,
|
|
1181
|
+
auditor: event.auditor,
|
|
1182
|
+
},
|
|
1183
|
+
);
|
|
1184
|
+
}
|
|
1185
|
+
return new WorkflowError(
|
|
1186
|
+
'TRANSITION_BLOCKED',
|
|
1187
|
+
`Guarded Step ${stepId} cannot continue because the corrective decision requires coordinator escalation.`,
|
|
1188
|
+
{
|
|
1189
|
+
taskId: task.id,
|
|
1190
|
+
stepId,
|
|
1191
|
+
attemptCount,
|
|
1192
|
+
decision: event.decision,
|
|
1193
|
+
decisionEventId: event.eventId,
|
|
1194
|
+
auditor: event.auditor,
|
|
1195
|
+
},
|
|
1196
|
+
);
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
function requireGuardedCategory(
|
|
1200
|
+
planRiskAudit: CurrentPlanRiskAudit,
|
|
1201
|
+
stepId: string,
|
|
1202
|
+
): GuardedRiskCategory {
|
|
1203
|
+
const classification = planRiskAudit.audit.stepClassifications.find((candidate) => candidate.stepId === stepId) ?? null;
|
|
1204
|
+
if (!classification || classification.categories.length === 0 || !classification.reviewRequired) {
|
|
1205
|
+
throw new WorkflowError(
|
|
1206
|
+
'STATE_CORRUPT',
|
|
1207
|
+
`Current Plan Risk Audit does not define a guarded category for ${stepId}.`,
|
|
1208
|
+
{
|
|
1209
|
+
taskId: planRiskAudit.audit.taskId,
|
|
1210
|
+
stepId,
|
|
1211
|
+
},
|
|
1212
|
+
);
|
|
1213
|
+
}
|
|
1214
|
+
return classification.categories[0]!;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
function validateStoredRemediationEvent(
|
|
1218
|
+
event: RemediationEvent,
|
|
1219
|
+
task: TaskState,
|
|
1220
|
+
file: string,
|
|
1221
|
+
line: number,
|
|
1222
|
+
): void {
|
|
1223
|
+
if (event.taskId !== task.id) {
|
|
1224
|
+
throw new WorkflowError('STATE_CORRUPT', 'Remediation event taskId does not match the current Task.', {
|
|
1225
|
+
file,
|
|
1226
|
+
line,
|
|
1227
|
+
taskId: task.id,
|
|
1228
|
+
eventTaskId: event.taskId,
|
|
1229
|
+
});
|
|
1230
|
+
}
|
|
1231
|
+
if (typeof event.stepId !== 'string' || !event.stepId.trim()) {
|
|
1232
|
+
throw new WorkflowError('STATE_CORRUPT', 'Remediation event is missing stepId.', { file, line, taskId: task.id });
|
|
1233
|
+
}
|
|
1234
|
+
if (!REMEDIATION_FAILURE_KINDS.has(event.failureKind)) {
|
|
1235
|
+
throw new WorkflowError('STATE_CORRUPT', 'Remediation event has an unknown failureKind.', {
|
|
1236
|
+
file,
|
|
1237
|
+
line,
|
|
1238
|
+
taskId: task.id,
|
|
1239
|
+
stepId: event.stepId,
|
|
1240
|
+
failureKind: event.failureKind,
|
|
1241
|
+
});
|
|
1242
|
+
}
|
|
1243
|
+
if (!REMEDIATION_MODES.has(event.mode)) {
|
|
1244
|
+
throw new WorkflowError('STATE_CORRUPT', 'Remediation event has an unknown mode.', {
|
|
1245
|
+
file,
|
|
1246
|
+
line,
|
|
1247
|
+
taskId: task.id,
|
|
1248
|
+
stepId: event.stepId,
|
|
1249
|
+
mode: event.mode,
|
|
1250
|
+
});
|
|
1251
|
+
}
|
|
1252
|
+
assertCommitSha(event.failureEvidence.completionCommit, 'Remediation failure evidence completionCommit', file, line);
|
|
1253
|
+
assertHash64(event.failureEvidence.checksHash, `Remediation failure evidence checksHash at ${file}:${line}`);
|
|
1254
|
+
if (event.failureEvidence.reviewEventHash !== null) {
|
|
1255
|
+
assertHash64(event.failureEvidence.reviewEventHash, `Remediation failure evidence reviewEventHash at ${file}:${line}`);
|
|
1256
|
+
}
|
|
1257
|
+
if (event.failureEvidence.reviewerAttestationHash !== null) {
|
|
1258
|
+
assertHash64(
|
|
1259
|
+
event.failureEvidence.reviewerAttestationHash,
|
|
1260
|
+
`Remediation failure evidence reviewerAttestationHash at ${file}:${line}`,
|
|
1261
|
+
);
|
|
1262
|
+
}
|
|
1263
|
+
const hasExecutionRevision = event.failureEvidence.executionTaskRevision !== undefined;
|
|
1264
|
+
const hasDirtyWorktreeHash = event.failureEvidence.dirtyWorktreeHash !== undefined;
|
|
1265
|
+
if (hasExecutionRevision !== hasDirtyWorktreeHash) {
|
|
1266
|
+
throw new WorkflowError('STATE_CORRUPT', 'Remediation execution revision and dirty-worktree hash must be recorded together.', {
|
|
1267
|
+
file,
|
|
1268
|
+
line,
|
|
1269
|
+
taskId: task.id,
|
|
1270
|
+
stepId: event.stepId,
|
|
1271
|
+
eventId: event.eventId,
|
|
1272
|
+
});
|
|
1273
|
+
}
|
|
1274
|
+
if (hasExecutionRevision
|
|
1275
|
+
&& (!Number.isInteger(event.failureEvidence.executionTaskRevision)
|
|
1276
|
+
|| event.failureEvidence.executionTaskRevision! <= 0)) {
|
|
1277
|
+
throw new WorkflowError('STATE_CORRUPT', 'Remediation execution task revision must be a positive integer.', {
|
|
1278
|
+
file,
|
|
1279
|
+
line,
|
|
1280
|
+
taskId: task.id,
|
|
1281
|
+
stepId: event.stepId,
|
|
1282
|
+
eventId: event.eventId,
|
|
1283
|
+
executionTaskRevision: event.failureEvidence.executionTaskRevision,
|
|
1284
|
+
});
|
|
1285
|
+
}
|
|
1286
|
+
if (hasDirtyWorktreeHash) {
|
|
1287
|
+
assertHash64(
|
|
1288
|
+
event.failureEvidence.dirtyWorktreeHash!,
|
|
1289
|
+
`Remediation dirty-worktree hash at ${file}:${line}`,
|
|
1290
|
+
);
|
|
1291
|
+
}
|
|
1292
|
+
const boundary = event.failureEvidence.unclaimedExecutionBoundary;
|
|
1293
|
+
if (boundary !== undefined) {
|
|
1294
|
+
if (!boundary || typeof boundary !== 'object' || Array.isArray(boundary)
|
|
1295
|
+
|| Object.keys(boundary).sort().join(',') !== 'actor,kind,priorHandoffEventHash'
|
|
1296
|
+
|| boundary.kind !== 'native-unclaimed-check-failure'
|
|
1297
|
+
|| typeof boundary.actor !== 'string' || !boundary.actor.trim() || boundary.actor !== boundary.actor.trim()
|
|
1298
|
+
|| !(boundary.priorHandoffEventHash === null || typeof boundary.priorHandoffEventHash === 'string' && /^[a-f0-9]{64}$/.test(boundary.priorHandoffEventHash))
|
|
1299
|
+
|| event.failureKind !== 'checks-failed' || !hasExecutionRevision || !hasDirtyWorktreeHash) {
|
|
1300
|
+
throw new WorkflowError('STATE_CORRUPT', 'Invalid native unclaimed execution boundary.', { file, line, eventId: event.eventId });
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
if (!Number.isInteger(event.attemptOrdinal) || event.attemptOrdinal <= 0) {
|
|
1304
|
+
throw new WorkflowError('STATE_CORRUPT', 'Remediation event attemptOrdinal must be a positive integer.', {
|
|
1305
|
+
file,
|
|
1306
|
+
line,
|
|
1307
|
+
taskId: task.id,
|
|
1308
|
+
stepId: event.stepId,
|
|
1309
|
+
attemptOrdinal: event.attemptOrdinal,
|
|
1310
|
+
});
|
|
1311
|
+
}
|
|
1312
|
+
if (event.causeBindings !== undefined) {
|
|
1313
|
+
const fingerprints = new Set<string>();
|
|
1314
|
+
for (const cause of event.causeBindings) {
|
|
1315
|
+
if (!['mechanical', 'external-reviewer'].includes(cause.authority)
|
|
1316
|
+
|| !cause.causeId?.trim()
|
|
1317
|
+
|| !/^[a-f0-9]{64}$/.test(cause.causeFingerprint)
|
|
1318
|
+
|| typeof cause.planConflict !== 'boolean'
|
|
1319
|
+
|| (cause.authority === 'mechanical'
|
|
1320
|
+
&& (cause.planConflict || cause.causeId !== `mechanical:${cause.causeFingerprint}`))
|
|
1321
|
+
|| (cause.authority === 'external-reviewer'
|
|
1322
|
+
&& reviewerCauseFingerprint(cause.causeId) !== cause.causeFingerprint)) {
|
|
1323
|
+
throw new WorkflowError('STATE_CORRUPT', 'Remediation cause binding is invalid.', {
|
|
1324
|
+
file, line, eventId: event.eventId,
|
|
1325
|
+
});
|
|
1326
|
+
}
|
|
1327
|
+
if (fingerprints.has(cause.causeFingerprint)) {
|
|
1328
|
+
throw new WorkflowError('STATE_CORRUPT', 'Remediation cause fingerprints must be unique per event.', {
|
|
1329
|
+
file, line, eventId: event.eventId,
|
|
1330
|
+
});
|
|
1331
|
+
}
|
|
1332
|
+
fingerprints.add(cause.causeFingerprint);
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
if (event.failureKind === 'checks-failed') {
|
|
1336
|
+
if (event.failureEvidence.reviewEventHash !== null
|
|
1337
|
+
|| event.failureEvidence.reviewerAttestationHash !== null
|
|
1338
|
+
|| event.causeBindings?.some((cause) => cause.authority !== 'mechanical')) {
|
|
1339
|
+
throw new WorkflowError('STATE_CORRUPT', 'checks-failed remediation events must not reference strict review sidecars.', {
|
|
1340
|
+
file,
|
|
1341
|
+
line,
|
|
1342
|
+
taskId: task.id,
|
|
1343
|
+
stepId: event.stepId,
|
|
1344
|
+
eventId: event.eventId,
|
|
1345
|
+
});
|
|
1346
|
+
}
|
|
1347
|
+
if (event.causeBindings !== undefined && (!hasExecutionRevision || !hasDirtyWorktreeHash)) {
|
|
1348
|
+
throw new WorkflowError('STATE_CORRUPT', 'Native checks-failed remediation events must bind the declared execution and dirty product bytes.', {
|
|
1349
|
+
file,
|
|
1350
|
+
line,
|
|
1351
|
+
taskId: task.id,
|
|
1352
|
+
stepId: event.stepId,
|
|
1353
|
+
eventId: event.eventId,
|
|
1354
|
+
});
|
|
1355
|
+
}
|
|
1356
|
+
} else if (event.failureEvidence.reviewEventHash === null
|
|
1357
|
+
|| event.failureEvidence.reviewerAttestationHash === null
|
|
1358
|
+
|| event.causeBindings?.some((cause) => cause.authority !== 'external-reviewer')
|
|
1359
|
+
|| hasExecutionRevision
|
|
1360
|
+
|| hasDirtyWorktreeHash) {
|
|
1361
|
+
throw new WorkflowError('STATE_CORRUPT', 'review-failed and review-unverified remediation events must reference both strict review sidecars.', {
|
|
1362
|
+
file,
|
|
1363
|
+
line,
|
|
1364
|
+
taskId: task.id,
|
|
1365
|
+
stepId: event.stepId,
|
|
1366
|
+
eventId: event.eventId,
|
|
1367
|
+
});
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
function validateStoredCorrectiveDecisionEvent(
|
|
1372
|
+
event: CorrectiveDecisionEvent,
|
|
1373
|
+
task: TaskState,
|
|
1374
|
+
file: string,
|
|
1375
|
+
line: number,
|
|
1376
|
+
): void {
|
|
1377
|
+
if (event.taskId !== task.id) {
|
|
1378
|
+
throw new WorkflowError('STATE_CORRUPT', 'Corrective decision taskId does not match the current Task.', {
|
|
1379
|
+
file,
|
|
1380
|
+
line,
|
|
1381
|
+
taskId: task.id,
|
|
1382
|
+
eventTaskId: event.taskId,
|
|
1383
|
+
});
|
|
1384
|
+
}
|
|
1385
|
+
if (typeof event.stepId !== 'string' || !event.stepId.trim()) {
|
|
1386
|
+
throw new WorkflowError('STATE_CORRUPT', 'Corrective decision is missing stepId.', { file, line, taskId: task.id });
|
|
1387
|
+
}
|
|
1388
|
+
if (!Number.isInteger(event.triggeringAttemptCount) || event.triggeringAttemptCount < 2) {
|
|
1389
|
+
throw new WorkflowError('STATE_CORRUPT', 'Corrective decision triggeringAttemptCount must be an integer >= 2.', {
|
|
1390
|
+
file,
|
|
1391
|
+
line,
|
|
1392
|
+
taskId: task.id,
|
|
1393
|
+
stepId: event.stepId,
|
|
1394
|
+
triggeringAttemptCount: event.triggeringAttemptCount,
|
|
1395
|
+
});
|
|
1396
|
+
}
|
|
1397
|
+
if (!CORRECTIVE_DECISION_VALUES.has(event.decision)) {
|
|
1398
|
+
throw new WorkflowError('STATE_CORRUPT', 'Corrective decision has an unknown decision value.', {
|
|
1399
|
+
file,
|
|
1400
|
+
line,
|
|
1401
|
+
taskId: task.id,
|
|
1402
|
+
stepId: event.stepId,
|
|
1403
|
+
decision: event.decision,
|
|
1404
|
+
});
|
|
1405
|
+
}
|
|
1406
|
+
if (typeof event.auditor !== 'string' || !event.auditor.trim()) {
|
|
1407
|
+
throw new WorkflowError('STATE_CORRUPT', 'Corrective decision is missing auditor.', {
|
|
1408
|
+
file,
|
|
1409
|
+
line,
|
|
1410
|
+
taskId: task.id,
|
|
1411
|
+
stepId: event.stepId,
|
|
1412
|
+
});
|
|
1413
|
+
}
|
|
1414
|
+
normalizeStringList(event.coveredFindingIds, 'Corrective decision coveredFindingIds');
|
|
1415
|
+
normalizeStringList(event.coveredEventIds, 'Corrective decision coveredEventIds');
|
|
1416
|
+
assertHash64(event.planHash, `Corrective decision planHash at ${file}:${line}`);
|
|
1417
|
+
if (event.recoveryBasis === 'plan-integrity' || event.planIntegrityEvidence !== undefined) {
|
|
1418
|
+
validatePlanIntegrityDecision(event, task, file, line);
|
|
1419
|
+
} else if (event.triggeringAttemptCount === 2) {
|
|
1420
|
+
throw new WorkflowError('STATE_CORRUPT', 'A second-attempt corrective decision requires bounded Plan-integrity evidence.', {
|
|
1421
|
+
file, line, taskId: task.id, stepId: event.stepId,
|
|
1422
|
+
});
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
function validateStoredStopEscalateOverride(
|
|
1427
|
+
event: StopEscalateOverrideEvent,
|
|
1428
|
+
task: TaskState,
|
|
1429
|
+
file: string,
|
|
1430
|
+
line: number,
|
|
1431
|
+
): void {
|
|
1432
|
+
if (!/^SOO-[0-9A-HJKMNP-TV-Z]{26}$/.test(event.eventId)) {
|
|
1433
|
+
throw new WorkflowError('STATE_CORRUPT', 'Stop override eventId is malformed.', { file, line });
|
|
1434
|
+
}
|
|
1435
|
+
if (
|
|
1436
|
+
event.taskId !== task.id
|
|
1437
|
+
|| !Number.isInteger(event.taskRevision)
|
|
1438
|
+
|| event.taskRevision < 1
|
|
1439
|
+
|| event.taskRevision > task.revision
|
|
1440
|
+
|| typeof event.stepId !== 'string'
|
|
1441
|
+
|| !event.stepId.trim()
|
|
1442
|
+
|| event.triggeringAttemptCount !== 4
|
|
1443
|
+
|| event.policy !== LEGACY_STOP_OVERRIDE_POLICY
|
|
1444
|
+
) {
|
|
1445
|
+
throw new WorkflowError('STATE_CORRUPT', 'Stop override does not match its Task or compatibility policy.', {
|
|
1446
|
+
file,
|
|
1447
|
+
line,
|
|
1448
|
+
taskId: task.id,
|
|
1449
|
+
eventTaskId: event.taskId,
|
|
1450
|
+
});
|
|
1451
|
+
}
|
|
1452
|
+
if (
|
|
1453
|
+
!/^CRD-[0-9A-HJKMNP-TV-Z]{26}$/.test(event.stopDecisionEventId)
|
|
1454
|
+
|| !/^CRD-[0-9A-HJKMNP-TV-Z]{26}$/.test(event.priorContinueDecisionEventId)
|
|
1455
|
+
|| !COMMIT_SHA_PATTERN.test(event.headCommit)
|
|
1456
|
+
|| typeof event.actor !== 'string'
|
|
1457
|
+
|| !event.actor.trim()
|
|
1458
|
+
|| typeof event.reason !== 'string'
|
|
1459
|
+
|| !event.reason.trim()
|
|
1460
|
+
|| typeof event.packageVersion !== 'string'
|
|
1461
|
+
|| !event.packageVersion.trim()
|
|
1462
|
+
) {
|
|
1463
|
+
throw new WorkflowError('STATE_CORRUPT', 'Stop override identifiers or human evidence are malformed.', {
|
|
1464
|
+
file,
|
|
1465
|
+
line,
|
|
1466
|
+
taskId: task.id,
|
|
1467
|
+
});
|
|
1468
|
+
}
|
|
1469
|
+
const coveredEventIds = normalizeStringList(event.coveredEventIds, 'Stop override coveredEventIds');
|
|
1470
|
+
if (coveredEventIds.length !== 3 || new Set(coveredEventIds).size !== coveredEventIds.length) {
|
|
1471
|
+
throw new WorkflowError('STATE_CORRUPT', 'Stop override must bind exactly three distinct remediation events.', {
|
|
1472
|
+
file,
|
|
1473
|
+
line,
|
|
1474
|
+
taskId: task.id,
|
|
1475
|
+
stepId: event.stepId,
|
|
1476
|
+
});
|
|
1477
|
+
}
|
|
1478
|
+
for (const [label, value] of [
|
|
1479
|
+
['stopDecisionEventHash', event.stopDecisionEventHash],
|
|
1480
|
+
['priorContinueDecisionEventHash', event.priorContinueDecisionEventHash],
|
|
1481
|
+
['planHash', event.planHash],
|
|
1482
|
+
['confirmationCodeBindingHash', event.confirmationCodeBindingHash],
|
|
1483
|
+
] as const) {
|
|
1484
|
+
assertHash64(value, `Stop override ${label} at ${file}:${line}`);
|
|
1485
|
+
}
|
|
1486
|
+
const expectedBindingHash = stopEscalateOverrideBindingHash(event);
|
|
1487
|
+
if (event.confirmationCodeBindingHash !== expectedBindingHash) {
|
|
1488
|
+
throw new WorkflowError('STATE_CORRUPT', 'Stop override confirmation binding hash is invalid.', {
|
|
1489
|
+
file,
|
|
1490
|
+
line,
|
|
1491
|
+
taskId: task.id,
|
|
1492
|
+
expectedBindingHash,
|
|
1493
|
+
actualBindingHash: event.confirmationCodeBindingHash,
|
|
1494
|
+
});
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
function validateStopEscalateOverrideHistory(
|
|
1499
|
+
task: TaskState,
|
|
1500
|
+
remediations: readonly RemediationEvent[],
|
|
1501
|
+
decisions: readonly CorrectiveDecisionEvent[],
|
|
1502
|
+
overrides: readonly StopEscalateOverrideEvent[],
|
|
1503
|
+
): Set<string> {
|
|
1504
|
+
const overriddenDecisionIds = new Set<string>();
|
|
1505
|
+
for (const override of overrides) {
|
|
1506
|
+
if (overriddenDecisionIds.has(override.stopDecisionEventId)) {
|
|
1507
|
+
throw new WorkflowError('STATE_CORRUPT', 'Multiple stop overrides target one corrective decision.', {
|
|
1508
|
+
taskId: task.id,
|
|
1509
|
+
stepId: override.stepId,
|
|
1510
|
+
stopDecisionEventId: override.stopDecisionEventId,
|
|
1511
|
+
});
|
|
1512
|
+
}
|
|
1513
|
+
const stepRemediations = remediations
|
|
1514
|
+
.filter((event) => event.stepId === override.stepId && event.attemptOrdinal <= 3)
|
|
1515
|
+
.sort((left, right) => left.attemptOrdinal - right.attemptOrdinal);
|
|
1516
|
+
const stopDecision = decisions.find((event) => event.eventId === override.stopDecisionEventId) ?? null;
|
|
1517
|
+
const priorContinue = decisions.find((event) => event.eventId === override.priorContinueDecisionEventId) ?? null;
|
|
1518
|
+
if (
|
|
1519
|
+
stepRemediations.length !== 3
|
|
1520
|
+
|| stepRemediations[0]?.attemptOrdinal !== 1
|
|
1521
|
+
|| stepRemediations[0]?.mode !== 'ordinary'
|
|
1522
|
+
|| stepRemediations[1]?.attemptOrdinal !== 2
|
|
1523
|
+
|| stepRemediations[1]?.mode !== 'ordinary'
|
|
1524
|
+
|| stepRemediations[2]?.attemptOrdinal !== 3
|
|
1525
|
+
|| stepRemediations[2]?.mode !== 'corrective'
|
|
1526
|
+
|| !stopDecision
|
|
1527
|
+
|| stopDecision.eventHash !== override.stopDecisionEventHash
|
|
1528
|
+
|| stopDecision.decision !== 'stop-escalate'
|
|
1529
|
+
|| stopDecision.triggeringAttemptCount !== 4
|
|
1530
|
+
|| stopDecision.planHash !== override.planHash
|
|
1531
|
+
|| !priorContinue
|
|
1532
|
+
|| priorContinue.eventHash !== override.priorContinueDecisionEventHash
|
|
1533
|
+
|| priorContinue.decision !== 'continue-fix'
|
|
1534
|
+
|| priorContinue.triggeringAttemptCount !== 3
|
|
1535
|
+
|| priorContinue.planHash !== override.planHash
|
|
1536
|
+
|| !sameStringArray(override.coveredEventIds, stepRemediations.map((event) => event.eventId))
|
|
1537
|
+
|| !sameStringArray(stopDecision.coveredEventIds, override.coveredEventIds)
|
|
1538
|
+
|| !sameStringArray(priorContinue.coveredEventIds, override.coveredEventIds.slice(0, 2))
|
|
1539
|
+
) {
|
|
1540
|
+
throw new WorkflowError('STATE_CORRUPT', 'Stop override does not bind the exact beta.11 attempt-4 stop history.', {
|
|
1541
|
+
taskId: task.id,
|
|
1542
|
+
stepId: override.stepId,
|
|
1543
|
+
overrideEventId: override.eventId,
|
|
1544
|
+
});
|
|
1545
|
+
}
|
|
1546
|
+
overriddenDecisionIds.add(override.stopDecisionEventId);
|
|
1547
|
+
}
|
|
1548
|
+
return overriddenDecisionIds;
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
function stopEscalateOverrideBindingHash(input: {
|
|
1552
|
+
taskId: string;
|
|
1553
|
+
taskRevision: number;
|
|
1554
|
+
stepId: string;
|
|
1555
|
+
stopDecisionEventId: string;
|
|
1556
|
+
stopDecisionEventHash: string;
|
|
1557
|
+
priorContinueDecisionEventId: string;
|
|
1558
|
+
priorContinueDecisionEventHash: string;
|
|
1559
|
+
triggeringAttemptCount: 4;
|
|
1560
|
+
coveredEventIds: readonly string[];
|
|
1561
|
+
planHash: string;
|
|
1562
|
+
headCommit: string;
|
|
1563
|
+
actor: string;
|
|
1564
|
+
reason: string;
|
|
1565
|
+
packageVersion: string;
|
|
1566
|
+
policy: typeof LEGACY_STOP_OVERRIDE_POLICY;
|
|
1567
|
+
}): string {
|
|
1568
|
+
return hashCanonical({
|
|
1569
|
+
domain: 'codex-workflow/stop-escalate-override/v1',
|
|
1570
|
+
taskId: input.taskId,
|
|
1571
|
+
taskRevision: input.taskRevision,
|
|
1572
|
+
stepId: input.stepId,
|
|
1573
|
+
stopDecisionEventId: input.stopDecisionEventId,
|
|
1574
|
+
stopDecisionEventHash: input.stopDecisionEventHash,
|
|
1575
|
+
priorContinueDecisionEventId: input.priorContinueDecisionEventId,
|
|
1576
|
+
priorContinueDecisionEventHash: input.priorContinueDecisionEventHash,
|
|
1577
|
+
triggeringAttemptCount: input.triggeringAttemptCount,
|
|
1578
|
+
coveredEventIds: [...input.coveredEventIds],
|
|
1579
|
+
planHash: input.planHash,
|
|
1580
|
+
headCommit: input.headCommit,
|
|
1581
|
+
actor: input.actor,
|
|
1582
|
+
reason: input.reason,
|
|
1583
|
+
packageVersion: input.packageVersion,
|
|
1584
|
+
policy: input.policy,
|
|
1585
|
+
});
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
function validatePlanIntegrityDecision(
|
|
1589
|
+
event: CorrectiveDecisionEvent,
|
|
1590
|
+
task: TaskState,
|
|
1591
|
+
file: string,
|
|
1592
|
+
line: number,
|
|
1593
|
+
): void {
|
|
1594
|
+
const evidence = event.planIntegrityEvidence;
|
|
1595
|
+
if (
|
|
1596
|
+
event.recoveryBasis !== 'plan-integrity'
|
|
1597
|
+
|| !evidence
|
|
1598
|
+
|| (evidence.kind !== 'failed-check-component-owner-outside-allowed-writes'
|
|
1599
|
+
&& evidence.manifestPath !== 'package.json')
|
|
1600
|
+
|| !Number.isInteger(evidence.taskRevision)
|
|
1601
|
+
|| evidence.taskRevision < 1
|
|
1602
|
+
|| evidence.taskRevision > task.revision
|
|
1603
|
+
|| !/^[a-f0-9]{40,64}$/.test(evidence.gitHead)
|
|
1604
|
+
) {
|
|
1605
|
+
throw new WorkflowError('STATE_CORRUPT', 'Corrective decision is not a valid bounded Plan-integrity recovery.', {
|
|
1606
|
+
file,
|
|
1607
|
+
line,
|
|
1608
|
+
taskId: task.id,
|
|
1609
|
+
stepId: event.stepId,
|
|
1610
|
+
});
|
|
1611
|
+
}
|
|
1612
|
+
if (evidence.kind !== 'failed-check-component-owner-outside-allowed-writes'
|
|
1613
|
+
&& evidence.manifestHash !== null) {
|
|
1614
|
+
assertHash64(evidence.manifestHash, `Plan-integrity manifestHash at ${file}:${line}`);
|
|
1615
|
+
}
|
|
1616
|
+
assertHash64(evidence.stepDefinitionHash, `Plan-integrity stepDefinitionHash at ${file}:${line}`);
|
|
1617
|
+
assertHash64(evidence.conflictHash, `Plan-integrity conflictHash at ${file}:${line}`);
|
|
1618
|
+
const changedFiles = normalizeStringList(evidence.changedFiles, 'Plan-integrity changedFiles');
|
|
1619
|
+
let domain: string;
|
|
1620
|
+
if (evidence.kind === 'missing-npm-script-outside-allowed-writes') {
|
|
1621
|
+
if (event.decision !== 'replan-required'
|
|
1622
|
+
|| event.triggeringAttemptCount !== 2
|
|
1623
|
+
|| typeof evidence.remediationEventId !== 'string'
|
|
1624
|
+
|| !evidence.remediationEventId) {
|
|
1625
|
+
throw new WorkflowError('STATE_CORRUPT', 'Legacy Plan-integrity evidence is valid only for the exact second-attempt boundary.', {
|
|
1626
|
+
file, line, taskId: task.id, stepId: event.stepId,
|
|
1627
|
+
});
|
|
1628
|
+
}
|
|
1629
|
+
assertHash64(evidence.remediationEventHash, `Plan-integrity remediationEventHash at ${file}:${line}`);
|
|
1630
|
+
const missingScripts = normalizeStringList(evidence.missingScripts, 'Plan-integrity missingScripts');
|
|
1631
|
+
const checkCommands = normalizeStringList(evidence.checkCommands, 'Plan-integrity checkCommands');
|
|
1632
|
+
if (missingScripts.length === 0 || checkCommands.length === 0) {
|
|
1633
|
+
throw new WorkflowError('STATE_CORRUPT', 'Plan-integrity evidence requires at least one missing script and check command.', {
|
|
1634
|
+
file, line, taskId: task.id, stepId: event.stepId,
|
|
1635
|
+
});
|
|
1636
|
+
}
|
|
1637
|
+
domain = 'codex-workflow/plan-integrity-conflict/v1';
|
|
1638
|
+
} else if (evidence.kind === 'non-executable-npm-check-outside-allowed-writes') {
|
|
1639
|
+
if (event.decision !== 'replan-required') {
|
|
1640
|
+
throw new WorkflowError('STATE_CORRUPT', 'Plan-integrity v2 evidence requires replan-required.', {
|
|
1641
|
+
file, line, taskId: task.id, stepId: event.stepId,
|
|
1642
|
+
});
|
|
1643
|
+
}
|
|
1644
|
+
if (evidence.remediationEvents.length === 0
|
|
1645
|
+
|| event.triggeringAttemptCount !== evidence.remediationEvents.length + 1
|
|
1646
|
+
|| evidence.remediationEvents.some((binding, index) => {
|
|
1647
|
+
if (!binding.eventId || binding.attemptOrdinal !== index + 1) return true;
|
|
1648
|
+
assertHash64(binding.eventHash, `Plan-integrity remediation event hash at ${file}:${line}`);
|
|
1649
|
+
return false;
|
|
1650
|
+
})
|
|
1651
|
+
|| evidence.conflicts.length === 0
|
|
1652
|
+
|| evidence.conflicts.some((conflict) => {
|
|
1653
|
+
if (!['MISSING_NPM_SCRIPT', 'REQUIRED_SELECTOR_MISSING'].includes(conflict.reasonCode)
|
|
1654
|
+
|| !conflict.script.trim()
|
|
1655
|
+
|| !conflict.command.trim()) return true;
|
|
1656
|
+
if (conflict.runnerHash !== null) {
|
|
1657
|
+
assertHash64(conflict.runnerHash, `Plan-integrity runnerHash at ${file}:${line}`);
|
|
1658
|
+
}
|
|
1659
|
+
return conflict.reasonCode === 'MISSING_NPM_SCRIPT'
|
|
1660
|
+
? conflict.scriptCommand !== null || conflict.runnerPath !== null || conflict.runnerHash !== null
|
|
1661
|
+
: conflict.scriptCommand === null || conflict.runnerPath === null || conflict.runnerHash === null;
|
|
1662
|
+
})) {
|
|
1663
|
+
throw new WorkflowError('STATE_CORRUPT', 'Plan-integrity v2 evidence has invalid remediation or command-conflict bindings.', {
|
|
1664
|
+
file, line, taskId: task.id, stepId: event.stepId,
|
|
1665
|
+
});
|
|
1666
|
+
}
|
|
1667
|
+
domain = 'codex-workflow/plan-integrity-conflict/v2';
|
|
1668
|
+
} else if (evidence.kind === 'check-support-anchor-outside-allowed-writes') {
|
|
1669
|
+
const step = task.steps.find((candidate) => candidate.id === event.stepId) ?? null;
|
|
1670
|
+
const supportPaths = [...new Set(evidence.conflicts.map((conflict) => conflict.supportPath))].sort();
|
|
1671
|
+
const validateCurrentPlanBinding = event.planHash === task.planHash;
|
|
1672
|
+
if (event.decision !== 'continue-fix'
|
|
1673
|
+
|| evidence.remediationEvents.length === 0
|
|
1674
|
+
|| event.triggeringAttemptCount !== evidence.remediationEvents.at(-1)!.attemptOrdinal + 1
|
|
1675
|
+
|| evidence.remediationEvents.some((binding, index) => {
|
|
1676
|
+
if (!binding.eventId || binding.attemptOrdinal !== evidence.remediationEvents[0]!.attemptOrdinal + index) return true;
|
|
1677
|
+
assertHash64(binding.eventHash, `Plan-integrity remediation event hash at ${file}:${line}`);
|
|
1678
|
+
return false;
|
|
1679
|
+
})
|
|
1680
|
+
|| evidence.conflicts.length === 0
|
|
1681
|
+
|| !sameStringArray(evidence.scopeExtensions, supportPaths)
|
|
1682
|
+
|| (validateCurrentPlanBinding && (!step
|
|
1683
|
+
|| evidence.scopeExtensions.some((scopePath) => pathAllowed(scopePath, step.allowedWrites)
|
|
1684
|
+
|| pathAllowed(scopePath, step.forbiddenScope))
|
|
1685
|
+
|| changedFiles.some((changedPath) => !pathAllowed(changedPath, step.allowedWrites))))
|
|
1686
|
+
|| evidence.conflicts.some((conflict) => {
|
|
1687
|
+
assertHash64(conflict.runnerHash, `Plan-integrity runnerHash at ${file}:${line}`);
|
|
1688
|
+
assertHash64(conflict.supportHash, `Plan-integrity supportHash at ${file}:${line}`);
|
|
1689
|
+
return conflict.reasonCode !== 'CHECK_SUPPORT_ANCHOR_OUTSIDE_ALLOWED_WRITES'
|
|
1690
|
+
|| !conflict.script.trim()
|
|
1691
|
+
|| !conflict.command.trim()
|
|
1692
|
+
|| !conflict.scriptCommand.trim()
|
|
1693
|
+
|| !conflict.runnerPath.trim()
|
|
1694
|
+
|| !conflict.supportPath.trim()
|
|
1695
|
+
|| !conflict.importSpecifier.startsWith('.')
|
|
1696
|
+
|| normalizeStringList(conflict.expectedOutputPaths, 'Plan-integrity expectedOutputPaths').length === 0
|
|
1697
|
+
|| normalizeStringList(conflict.anchoredMigrationNames, 'Plan-integrity anchoredMigrationNames').length === 0
|
|
1698
|
+
|| !evidence.scopeExtensions.includes(conflict.supportPath)
|
|
1699
|
+
|| (validateCurrentPlanBinding && (!step || conflict.expectedOutputPaths.some((output) =>
|
|
1700
|
+
!step.expectedOutputs.some((declared) =>
|
|
1701
|
+
(declared.startsWith('path:') ? declared.slice(5) : declared) === output)
|
|
1702
|
+
|| !pathAllowed(output, step.allowedWrites))));
|
|
1703
|
+
})) {
|
|
1704
|
+
throw new WorkflowError('STATE_CORRUPT', 'Plan-integrity v3 evidence has invalid check-support bindings.', {
|
|
1705
|
+
file, line, taskId: task.id, stepId: event.stepId,
|
|
1706
|
+
});
|
|
1707
|
+
}
|
|
1708
|
+
domain = 'codex-workflow/plan-integrity-conflict/v3';
|
|
1709
|
+
} else if (evidence.kind === 'failed-check-component-owner-outside-allowed-writes') {
|
|
1710
|
+
const step = task.steps.find((candidate) => candidate.id === event.stepId) ?? null;
|
|
1711
|
+
const ownerPaths = [...new Set(evidence.conflicts.map((conflict) => conflict.ownerPath))].sort();
|
|
1712
|
+
const validateCurrentPlanBinding = event.planHash === task.planHash;
|
|
1713
|
+
assertHash64(evidence.semanticBoundaryHash, `Plan-integrity semanticBoundaryHash at ${file}:${line}`);
|
|
1714
|
+
assertHash64(evidence.failedCheckCauseFingerprint, `Plan-integrity failedCheckCauseFingerprint at ${file}:${line}`);
|
|
1715
|
+
if (event.decision !== 'continue-fix'
|
|
1716
|
+
|| evidence.remediationEvents.length === 0
|
|
1717
|
+
|| event.triggeringAttemptCount !== evidence.remediationEvents.at(-1)!.attemptOrdinal + 1
|
|
1718
|
+
|| evidence.remediationEvents.some((binding, index) => {
|
|
1719
|
+
if (!binding.eventId || binding.attemptOrdinal !== evidence.remediationEvents[0]!.attemptOrdinal + index) return true;
|
|
1720
|
+
assertHash64(binding.eventHash, `Plan-integrity remediation event hash at ${file}:${line}`);
|
|
1721
|
+
return false;
|
|
1722
|
+
})
|
|
1723
|
+
|| evidence.conflicts.length === 0
|
|
1724
|
+
|| !sameStringArray(evidence.scopeExtensions, ownerPaths)
|
|
1725
|
+
|| (validateCurrentPlanBinding && (!step
|
|
1726
|
+
|| evidence.semanticBoundaryHash !== semanticBoundaryHash(task)
|
|
1727
|
+
|| evidence.scopeExtensions.some((scopePath) => pathAllowed(scopePath, step.allowedWrites)
|
|
1728
|
+
|| pathAllowed(scopePath, step.forbiddenScope))
|
|
1729
|
+
|| changedFiles.some((changedPath) => !pathAllowed(changedPath, step.allowedWrites))))
|
|
1730
|
+
|| evidence.conflicts.some((conflict) => {
|
|
1731
|
+
assertHash64(conflict.ownerHash, `Plan-integrity component owner hash at ${file}:${line}`);
|
|
1732
|
+
const importChain = normalizeStringList(conflict.importChain, 'Plan-integrity importChain');
|
|
1733
|
+
return conflict.reasonCode !== 'FAILED_CHECK_COMPONENT_OWNER_OUTSIDE_ALLOWED_WRITES'
|
|
1734
|
+
|| !conflict.command.trim()
|
|
1735
|
+
|| !conflict.runnerPath.trim()
|
|
1736
|
+
|| !conflict.ownerPath.trim()
|
|
1737
|
+
|| conflict.diagnosticPath !== conflict.ownerPath
|
|
1738
|
+
|| !conflict.componentRoot.trim()
|
|
1739
|
+
|| !(conflict.ownerPath === conflict.componentRoot
|
|
1740
|
+
|| conflict.ownerPath.startsWith(`${conflict.componentRoot}/`))
|
|
1741
|
+
|| (conflict.testInvocation
|
|
1742
|
+
? !validLiteralTestInvocationEvidence(conflict) || importChain.length !== 1
|
|
1743
|
+
: importChain.length < 2 || importChain.at(-1) !== conflict.ownerPath)
|
|
1744
|
+
|| importChain[0] !== conflict.runnerPath
|
|
1745
|
+
|| !evidence.scopeExtensions.includes(conflict.ownerPath);
|
|
1746
|
+
})) {
|
|
1747
|
+
throw new WorkflowError('STATE_CORRUPT', 'Plan-integrity v4 evidence has invalid component-owner bindings.', {
|
|
1748
|
+
file, line, taskId: task.id, stepId: event.stepId,
|
|
1749
|
+
});
|
|
1750
|
+
}
|
|
1751
|
+
domain = 'codex-workflow/plan-integrity-conflict/v4';
|
|
1752
|
+
} else {
|
|
1753
|
+
throw new WorkflowError('STATE_CORRUPT', 'Plan-integrity evidence kind is unsupported.', {
|
|
1754
|
+
file, line, taskId: task.id, stepId: event.stepId,
|
|
1755
|
+
});
|
|
1756
|
+
}
|
|
1757
|
+
const { conflictHash: _conflictHash, ...evidenceBase } = evidence;
|
|
1758
|
+
const expectedConflictHash = hashCanonical({
|
|
1759
|
+
domain,
|
|
1760
|
+
taskId: task.id,
|
|
1761
|
+
planHash: event.planHash,
|
|
1762
|
+
stepId: event.stepId,
|
|
1763
|
+
evidence: evidenceBase,
|
|
1764
|
+
});
|
|
1765
|
+
if (evidence.conflictHash !== expectedConflictHash) {
|
|
1766
|
+
throw new WorkflowError('STATE_CORRUPT', 'Plan-integrity evidence conflictHash is invalid.', {
|
|
1767
|
+
file,
|
|
1768
|
+
line,
|
|
1769
|
+
taskId: task.id,
|
|
1770
|
+
stepId: event.stepId,
|
|
1771
|
+
expectedConflictHash,
|
|
1772
|
+
actualConflictHash: evidence.conflictHash,
|
|
1773
|
+
});
|
|
1774
|
+
}
|
|
1775
|
+
if (changedFiles.some((value) => value.trim() !== value)) {
|
|
1776
|
+
throw new WorkflowError('STATE_CORRUPT', 'Plan-integrity changedFiles must be normalized.', { file, line });
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
/** A validated support decision survives only a hash-verified Knowledge-only Plan chain.
|
|
1781
|
+
* This preserves scope evidence, never execution authorization or a semantic replan. */
|
|
1782
|
+
export function isKnowledgeReboundSupportDecision(
|
|
1783
|
+
store: FileStateStore, task: TaskState, event: CorrectiveDecisionEvent,
|
|
1784
|
+
): boolean {
|
|
1785
|
+
const evidence = event.planIntegrityEvidence;
|
|
1786
|
+
if (!task.planHash || event.planHash === task.planHash || event.taskId !== task.id
|
|
1787
|
+
|| event.recoveryBasis !== 'plan-integrity' || event.decision !== 'continue-fix'
|
|
1788
|
+
|| (evidence?.kind !== 'check-support-anchor-outside-allowed-writes'
|
|
1789
|
+
&& evidence?.kind !== 'failed-check-component-owner-outside-allowed-writes')) return false;
|
|
1790
|
+
const step = task.steps.find(s => s.id === event.stepId);
|
|
1791
|
+
if (!step || evidence.stepDefinitionHash !== hashCanonical({
|
|
1792
|
+
id: step.id, title: step.title, objective: step.objective, requirements: step.requirements,
|
|
1793
|
+
dependencies: step.dependencies, expectedOutputs: step.expectedOutputs,
|
|
1794
|
+
allowedWrites: step.allowedWrites, forbiddenScope: step.forbiddenScope, checks: step.checks,
|
|
1795
|
+
}) || evidence.scopeExtensions.some(p => pathAllowed(p, step.allowedWrites) || pathAllowed(p, step.forbiddenScope))
|
|
1796
|
+
|| (evidence.kind === 'failed-check-component-owner-outside-allowed-writes'
|
|
1797
|
+
&& evidence.semanticBoundaryHash !== semanticBoundaryHash(task))) return false;
|
|
1798
|
+
const root = store.taskRoot(task.projectId, task.id);
|
|
1799
|
+
let cursor = task.planHash;
|
|
1800
|
+
const visited = new Set<string>();
|
|
1801
|
+
while (cursor !== event.planHash) {
|
|
1802
|
+
if (visited.has(cursor)) return false;
|
|
1803
|
+
visited.add(cursor);
|
|
1804
|
+
const links = task.knowledgeRebinds.filter(link => link.reboundPlanHash === cursor);
|
|
1805
|
+
if (links.length !== 1 || !links[0]!.previousPlanHash) return false;
|
|
1806
|
+
const link = links[0]!;
|
|
1807
|
+
const before = readVerifiedPlanArtifact(root, link.previousPlanHash!, task.planHash);
|
|
1808
|
+
const after = readVerifiedPlanArtifact(root, cursor, task.planHash);
|
|
1809
|
+
assertKnowledgeBindingOnlyPlanChange(before, after);
|
|
1810
|
+
const from = normalizePlanKnowledgeBinding(before, 'source').binding;
|
|
1811
|
+
const to = normalizePlanKnowledgeBinding(after, 'target').binding;
|
|
1812
|
+
if (from.revision !== link.fromKnowledgeMapRevision || from.hash !== link.fromKnowledgeMapHash
|
|
1813
|
+
|| to.revision !== link.toKnowledgeMapRevision || to.hash !== link.toKnowledgeMapHash) return false;
|
|
1814
|
+
cursor = link.previousPlanHash!;
|
|
1815
|
+
}
|
|
1816
|
+
return true;
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
export function effectiveStepAllowedWrites(
|
|
1820
|
+
store: FileStateStore,
|
|
1821
|
+
task: TaskState,
|
|
1822
|
+
stepId: string,
|
|
1823
|
+
): string[] {
|
|
1824
|
+
const step = task.steps.find((candidate) => candidate.id === stepId);
|
|
1825
|
+
if (!step) throw new WorkflowError('NOT_FOUND', `Step not found: ${stepId}`);
|
|
1826
|
+
const extensions = readCorrectiveDecisionEvents(store, task)
|
|
1827
|
+
.filter((event) => event.stepId === stepId
|
|
1828
|
+
&& (event.planHash === task.planHash || isKnowledgeReboundSupportDecision(store, task, event))
|
|
1829
|
+
&& event.decision === 'continue-fix'
|
|
1830
|
+
&& event.recoveryBasis === 'plan-integrity')
|
|
1831
|
+
.flatMap((event) => event.planIntegrityEvidence?.kind === 'check-support-anchor-outside-allowed-writes'
|
|
1832
|
+
|| event.planIntegrityEvidence?.kind === 'failed-check-component-owner-outside-allowed-writes'
|
|
1833
|
+
? event.planIntegrityEvidence.scopeExtensions
|
|
1834
|
+
: []);
|
|
1835
|
+
return [...step.allowedWrites, ...new Set(extensions.filter((value) => !pathAllowed(value, step.allowedWrites)).sort())];
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
function validateStoredCorrectiveDecisionRecoveryReference(
|
|
1839
|
+
event: CorrectiveDecisionRecoveryEvent,
|
|
1840
|
+
task: TaskState,
|
|
1841
|
+
file: string,
|
|
1842
|
+
line: number,
|
|
1843
|
+
): void {
|
|
1844
|
+
if (
|
|
1845
|
+
!/^CDR-[0-9A-HJKMNP-TV-Z]{26}$/.test(event.eventId)
|
|
1846
|
+
|| event.taskId !== task.id
|
|
1847
|
+
|| event.triggeringAttemptCount !== 3
|
|
1848
|
+
) {
|
|
1849
|
+
throw new WorkflowError('STATE_CORRUPT', 'Corrective recovery reference does not match the Task/ordinal.', {
|
|
1850
|
+
file,
|
|
1851
|
+
line,
|
|
1852
|
+
taskId: task.id,
|
|
1853
|
+
eventTaskId: event.taskId,
|
|
1854
|
+
triggeringAttemptCount: event.triggeringAttemptCount,
|
|
1855
|
+
});
|
|
1856
|
+
}
|
|
1857
|
+
if (typeof event.stepId !== 'string' || !event.stepId.trim() || typeof event.actor !== 'string' || !event.actor.trim()) {
|
|
1858
|
+
throw new WorkflowError('STATE_CORRUPT', 'Corrective recovery reference is missing Step or actor.', { file, line });
|
|
1859
|
+
}
|
|
1860
|
+
for (const hash of [
|
|
1861
|
+
event.correctiveDecisionEventHash,
|
|
1862
|
+
event.decisionPlanHash,
|
|
1863
|
+
event.reboundPlanHash,
|
|
1864
|
+
event.semanticPlanHash,
|
|
1865
|
+
event.fromKnowledgeMapHash,
|
|
1866
|
+
event.toKnowledgeMapHash,
|
|
1867
|
+
event.sourcePlanRiskAuditEventHash,
|
|
1868
|
+
event.reboundPlanRiskAuditEventHash,
|
|
1869
|
+
event.knowledgeMapApprovalFingerprint,
|
|
1870
|
+
event.taskAuthorizationFingerprint,
|
|
1871
|
+
event.delegationGrantFingerprint,
|
|
1872
|
+
event.c1EvidenceFingerprint,
|
|
1873
|
+
event.evidenceFingerprint,
|
|
1874
|
+
]) {
|
|
1875
|
+
assertHash64(hash, `Corrective recovery hash at ${file}:${line}`);
|
|
1876
|
+
}
|
|
1877
|
+
if (
|
|
1878
|
+
!Number.isInteger(event.fromKnowledgeMapRevision)
|
|
1879
|
+
|| event.fromKnowledgeMapRevision < 1
|
|
1880
|
+
|| !Number.isInteger(event.toKnowledgeMapRevision)
|
|
1881
|
+
|| event.toKnowledgeMapRevision < 1
|
|
1882
|
+
) {
|
|
1883
|
+
throw new WorkflowError('STATE_CORRUPT', 'Corrective recovery Knowledge Map revisions are malformed.', { file, line });
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
function validateStoredRemediationModeRecovery(
|
|
1888
|
+
event: RemediationModeRecoveryEvent,
|
|
1889
|
+
task: TaskState,
|
|
1890
|
+
file: string,
|
|
1891
|
+
line: number,
|
|
1892
|
+
): void {
|
|
1893
|
+
if (!/^RMR-[0-9A-HJKMNP-TV-Z]{26}$/.test(event.eventId)) {
|
|
1894
|
+
throw new WorkflowError('STATE_CORRUPT', 'Remediation mode recovery eventId is malformed.', { file, line });
|
|
1895
|
+
}
|
|
1896
|
+
if (event.taskId !== task.id || typeof event.stepId !== 'string' || !event.stepId.trim()) {
|
|
1897
|
+
throw new WorkflowError('STATE_CORRUPT', 'Remediation mode recovery does not match the current Task.', {
|
|
1898
|
+
file,
|
|
1899
|
+
line,
|
|
1900
|
+
taskId: task.id,
|
|
1901
|
+
eventTaskId: event.taskId,
|
|
1902
|
+
});
|
|
1903
|
+
}
|
|
1904
|
+
if (!Number.isInteger(event.taskRevision) || event.taskRevision < 1 || event.attemptOrdinal !== 3) {
|
|
1905
|
+
throw new WorkflowError('STATE_CORRUPT', 'Remediation mode recovery has an invalid revision or ordinal.', { file, line });
|
|
1906
|
+
}
|
|
1907
|
+
if (
|
|
1908
|
+
event.fromMode !== 'ordinary'
|
|
1909
|
+
|| event.toMode !== 'corrective'
|
|
1910
|
+
|| event.reason !== 'alpha7.2-recovered-continue-fix-mode-omission'
|
|
1911
|
+
) {
|
|
1912
|
+
throw new WorkflowError('STATE_CORRUPT', 'Remediation mode recovery has an unsupported correction.', { file, line });
|
|
1913
|
+
}
|
|
1914
|
+
if (
|
|
1915
|
+
!/^RME-[0-9A-HJKMNP-TV-Z]{26}$/.test(event.remediationEventId)
|
|
1916
|
+
|| !/^CRD-[0-9A-HJKMNP-TV-Z]{26}$/.test(event.correctiveDecisionEventId)
|
|
1917
|
+
|| !/^CDR-[0-9A-HJKMNP-TV-Z]{26}$/.test(event.correctiveDecisionRecoveryEventId)
|
|
1918
|
+
|| typeof event.actor !== 'string'
|
|
1919
|
+
|| !event.actor.trim()
|
|
1920
|
+
) {
|
|
1921
|
+
throw new WorkflowError('STATE_CORRUPT', 'Remediation mode recovery identifiers are malformed.', { file, line });
|
|
1922
|
+
}
|
|
1923
|
+
for (const [label, value] of [
|
|
1924
|
+
['remediationEventHash', event.remediationEventHash],
|
|
1925
|
+
['correctiveDecisionEventHash', event.correctiveDecisionEventHash],
|
|
1926
|
+
['correctiveDecisionRecoveryEventHash', event.correctiveDecisionRecoveryEventHash],
|
|
1927
|
+
['planHash', event.planHash],
|
|
1928
|
+
] as const) {
|
|
1929
|
+
assertHash64(value, `Remediation mode recovery ${label} at ${file}:${line}`);
|
|
1930
|
+
}
|
|
1931
|
+
if (event.c1ClaimEventHash !== null) assertHash64(event.c1ClaimEventHash, `Remediation mode recovery C1 hash at ${file}:${line}`);
|
|
1932
|
+
if (event.writerLeaseTokenHash !== null) assertHash64(event.writerLeaseTokenHash, `Remediation mode recovery token hash at ${file}:${line}`);
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
function validateRemediationModeRecoveryBinding(
|
|
1936
|
+
recovery: RemediationModeRecoveryEvent,
|
|
1937
|
+
remediation: RemediationEvent,
|
|
1938
|
+
decision: CorrectiveDecisionEvent,
|
|
1939
|
+
correctiveRecovery: CorrectiveDecisionRecoveryEvent,
|
|
1940
|
+
): void {
|
|
1941
|
+
const mismatches = [
|
|
1942
|
+
recovery.taskId !== remediation.taskId ? 'taskId' : null,
|
|
1943
|
+
recovery.stepId !== remediation.stepId ? 'stepId' : null,
|
|
1944
|
+
recovery.remediationEventId !== remediation.eventId ? 'remediationEventId' : null,
|
|
1945
|
+
recovery.remediationEventHash !== remediation.eventHash ? 'remediationEventHash' : null,
|
|
1946
|
+
recovery.correctiveDecisionEventId !== decision.eventId ? 'correctiveDecisionEventId' : null,
|
|
1947
|
+
recovery.correctiveDecisionEventHash !== decision.eventHash ? 'correctiveDecisionEventHash' : null,
|
|
1948
|
+
recovery.correctiveDecisionRecoveryEventId !== correctiveRecovery.eventId ? 'correctiveDecisionRecoveryEventId' : null,
|
|
1949
|
+
recovery.correctiveDecisionRecoveryEventHash !== correctiveRecovery.eventHash ? 'correctiveDecisionRecoveryEventHash' : null,
|
|
1950
|
+
recovery.attemptOrdinal !== remediation.attemptOrdinal ? 'attemptOrdinal' : null,
|
|
1951
|
+
recovery.planHash !== correctiveRecovery.reboundPlanHash ? 'planHash' : null,
|
|
1952
|
+
recovery.actor !== correctiveRecovery.actor ? 'actor' : null,
|
|
1953
|
+
].filter((value): value is string => value !== null);
|
|
1954
|
+
if (mismatches.length > 0) {
|
|
1955
|
+
throw new WorkflowError('STATE_CORRUPT', 'Remediation mode recovery does not match its append-only evidence.', {
|
|
1956
|
+
recoveryEventId: recovery.eventId,
|
|
1957
|
+
mismatches,
|
|
1958
|
+
});
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
function validateRemediationModeRecoveries(
|
|
1963
|
+
store: FileStateStore,
|
|
1964
|
+
task: TaskState,
|
|
1965
|
+
remediations: readonly RemediationEvent[],
|
|
1966
|
+
decisions: readonly CorrectiveDecisionEvent[],
|
|
1967
|
+
recoveries: readonly RemediationModeRecoveryEvent[],
|
|
1968
|
+
): Set<string> {
|
|
1969
|
+
const correctiveRecoveries = readRawCorrectiveDecisionRecoveries(store, task);
|
|
1970
|
+
const recoveredEventIds = new Set<string>();
|
|
1971
|
+
for (const recovery of recoveries) {
|
|
1972
|
+
if (recoveredEventIds.has(recovery.remediationEventId)) {
|
|
1973
|
+
throw new WorkflowError('STATE_CORRUPT', 'Duplicate remediation mode recoveries target one event.', {
|
|
1974
|
+
taskId: task.id,
|
|
1975
|
+
remediationEventId: recovery.remediationEventId,
|
|
1976
|
+
});
|
|
1977
|
+
}
|
|
1978
|
+
const remediation = remediations.find((event) => event.eventId === recovery.remediationEventId) ?? null;
|
|
1979
|
+
const decision = decisions.find((event) => event.eventId === recovery.correctiveDecisionEventId) ?? null;
|
|
1980
|
+
const correctiveRecovery = correctiveRecoveries.find(
|
|
1981
|
+
(event) => event.eventId === recovery.correctiveDecisionRecoveryEventId,
|
|
1982
|
+
) ?? null;
|
|
1983
|
+
if (!remediation || !decision || !correctiveRecovery) {
|
|
1984
|
+
throw new WorkflowError('STATE_CORRUPT', 'Remediation mode recovery references missing evidence.', {
|
|
1985
|
+
taskId: task.id,
|
|
1986
|
+
recoveryEventId: recovery.eventId,
|
|
1987
|
+
});
|
|
1988
|
+
}
|
|
1989
|
+
if (
|
|
1990
|
+
recovery.taskRevision !== task.revision
|
|
1991
|
+
|| recovery.planHash !== task.planHash
|
|
1992
|
+
|| remediation.mode !== 'ordinary'
|
|
1993
|
+
|| remediation.attemptOrdinal !== 3
|
|
1994
|
+
|| decision.decision !== 'continue-fix'
|
|
1995
|
+
|| decision.triggeringAttemptCount !== 3
|
|
1996
|
+
|| correctiveRecovery.correctiveDecisionEventId !== decision.eventId
|
|
1997
|
+
) {
|
|
1998
|
+
throw new WorkflowError('STATE_CORRUPT', 'Remediation mode recovery references an ineligible lifecycle shape.', {
|
|
1999
|
+
taskId: task.id,
|
|
2000
|
+
recoveryEventId: recovery.eventId,
|
|
2001
|
+
});
|
|
2002
|
+
}
|
|
2003
|
+
validateRemediationModeRecoveryBinding(recovery, remediation, decision, correctiveRecovery);
|
|
2004
|
+
recoveredEventIds.add(remediation.eventId);
|
|
2005
|
+
}
|
|
2006
|
+
return recoveredEventIds;
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
function validateRemediationAttemptOrdinals(
|
|
2010
|
+
events: readonly RemediationEvent[],
|
|
2011
|
+
task: TaskState,
|
|
2012
|
+
): void {
|
|
2013
|
+
const attemptsByStep = new Map<string, number>();
|
|
2014
|
+
for (const event of events) {
|
|
2015
|
+
const expectedAttemptOrdinal = (attemptsByStep.get(event.stepId) ?? 0) + 1;
|
|
2016
|
+
if (event.attemptOrdinal !== expectedAttemptOrdinal) {
|
|
2017
|
+
throw new WorkflowError(
|
|
2018
|
+
'STATE_CORRUPT',
|
|
2019
|
+
'Remediation event attemptOrdinal does not match the sidecar history for this Step.',
|
|
2020
|
+
{
|
|
2021
|
+
taskId: task.id,
|
|
2022
|
+
stepId: event.stepId,
|
|
2023
|
+
eventId: event.eventId,
|
|
2024
|
+
expectedAttemptOrdinal,
|
|
2025
|
+
actualAttemptOrdinal: event.attemptOrdinal,
|
|
2026
|
+
},
|
|
2027
|
+
);
|
|
2028
|
+
}
|
|
2029
|
+
attemptsByStep.set(event.stepId, event.attemptOrdinal);
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
function validateRemediationReviewReferences(
|
|
2034
|
+
store: FileStateStore,
|
|
2035
|
+
task: TaskState,
|
|
2036
|
+
events: readonly RemediationEvent[],
|
|
2037
|
+
): void {
|
|
2038
|
+
if (events.length === 0) return;
|
|
2039
|
+
const requiresStrictReviewLookups = events.some((event) =>
|
|
2040
|
+
event.failureEvidence.reviewEventHash !== null || event.failureEvidence.reviewerAttestationHash !== null,
|
|
2041
|
+
);
|
|
2042
|
+
if (!requiresStrictReviewLookups) {
|
|
2043
|
+
for (const event of events) {
|
|
2044
|
+
if (event.failureKind !== 'checks-failed') {
|
|
2045
|
+
throw new WorkflowError('STATE_CORRUPT', 'Only checks-failed remediation events may omit strict review references.', {
|
|
2046
|
+
taskId: task.id,
|
|
2047
|
+
stepId: event.stepId,
|
|
2048
|
+
eventId: event.eventId,
|
|
2049
|
+
failureKind: event.failureKind,
|
|
2050
|
+
});
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
return;
|
|
2054
|
+
}
|
|
2055
|
+
const reviewEvents = readStepReviewEvents(store, task);
|
|
2056
|
+
const reviewEventsByHash = new Map(reviewEvents.map((event) => [event.eventHash, event]));
|
|
2057
|
+
const reviewerAttestations = readReviewerAttestations(store, task);
|
|
2058
|
+
const reviewerAttestationsByHash = new Map(reviewerAttestations.map((event) => [event.eventHash, event]));
|
|
2059
|
+
const eventKeys = new Set<string>();
|
|
2060
|
+
for (const event of events) {
|
|
2061
|
+
const key = remediationFailureIdentityKey(event.failureKind, event.failureEvidence);
|
|
2062
|
+
if (eventKeys.has(`${event.stepId}\0${key}`)) {
|
|
2063
|
+
throw new WorkflowError('STATE_CORRUPT', 'Duplicate remediation events reference the same failure identity.', {
|
|
2064
|
+
taskId: task.id,
|
|
2065
|
+
stepId: event.stepId,
|
|
2066
|
+
eventId: event.eventId,
|
|
2067
|
+
});
|
|
2068
|
+
}
|
|
2069
|
+
eventKeys.add(`${event.stepId}\0${key}`);
|
|
2070
|
+
if (event.failureEvidence.reviewEventHash === null && event.failureEvidence.reviewerAttestationHash === null) {
|
|
2071
|
+
if (event.failureKind !== 'checks-failed') {
|
|
2072
|
+
throw new WorkflowError('STATE_CORRUPT', 'Only checks-failed remediation events may omit strict review references.', {
|
|
2073
|
+
taskId: task.id,
|
|
2074
|
+
stepId: event.stepId,
|
|
2075
|
+
eventId: event.eventId,
|
|
2076
|
+
failureKind: event.failureKind,
|
|
2077
|
+
});
|
|
2078
|
+
}
|
|
2079
|
+
continue;
|
|
2080
|
+
}
|
|
2081
|
+
if (event.failureEvidence.reviewEventHash === null || event.failureEvidence.reviewerAttestationHash === null) {
|
|
2082
|
+
throw new WorkflowError('STATE_CORRUPT', 'Remediation event must provide both reviewEventHash and reviewerAttestationHash together.', {
|
|
2083
|
+
taskId: task.id,
|
|
2084
|
+
stepId: event.stepId,
|
|
2085
|
+
eventId: event.eventId,
|
|
2086
|
+
});
|
|
2087
|
+
}
|
|
2088
|
+
const reviewEvent = reviewEventsByHash.get(event.failureEvidence.reviewEventHash) ?? null;
|
|
2089
|
+
const attestation = reviewerAttestationsByHash.get(event.failureEvidence.reviewerAttestationHash) ?? null;
|
|
2090
|
+
if (!reviewEvent || !attestation) {
|
|
2091
|
+
throw new WorkflowError('STATE_CORRUPT', 'Remediation event references missing strict review sidecar history.', {
|
|
2092
|
+
taskId: task.id,
|
|
2093
|
+
stepId: event.stepId,
|
|
2094
|
+
eventId: event.eventId,
|
|
2095
|
+
reviewEventHash: event.failureEvidence.reviewEventHash,
|
|
2096
|
+
reviewerAttestationHash: event.failureEvidence.reviewerAttestationHash,
|
|
2097
|
+
});
|
|
2098
|
+
}
|
|
2099
|
+
if (
|
|
2100
|
+
reviewEvent.taskId !== task.id
|
|
2101
|
+
|| reviewEvent.stepId !== event.stepId
|
|
2102
|
+
|| reviewEvent.completionCommit !== event.failureEvidence.completionCommit
|
|
2103
|
+
|| reviewEvent.checksHash !== event.failureEvidence.checksHash
|
|
2104
|
+
) {
|
|
2105
|
+
throw new WorkflowError('STATE_CORRUPT', 'Remediation event reviewEventHash does not match the exact strict review binding.', {
|
|
2106
|
+
taskId: task.id,
|
|
2107
|
+
stepId: event.stepId,
|
|
2108
|
+
eventId: event.eventId,
|
|
2109
|
+
reviewEventId: reviewEvent.eventId,
|
|
2110
|
+
});
|
|
2111
|
+
}
|
|
2112
|
+
if (
|
|
2113
|
+
attestation.taskId !== task.id
|
|
2114
|
+
|| attestation.stepId !== event.stepId
|
|
2115
|
+
|| attestation.reviewedCommit !== event.failureEvidence.completionCommit
|
|
2116
|
+
|| attestation.reviewEventHash !== reviewEvent.eventHash
|
|
2117
|
+
) {
|
|
2118
|
+
throw new WorkflowError('STATE_CORRUPT', 'Remediation event reviewerAttestationHash does not match the exact strict review binding.', {
|
|
2119
|
+
taskId: task.id,
|
|
2120
|
+
stepId: event.stepId,
|
|
2121
|
+
eventId: event.eventId,
|
|
2122
|
+
reviewerAttestationEventId: attestation.eventId,
|
|
2123
|
+
});
|
|
2124
|
+
}
|
|
2125
|
+
const expectedDecision = event.failureKind === 'review-failed' ? 'failed' : 'unverified';
|
|
2126
|
+
if (reviewEvent.decision !== expectedDecision) {
|
|
2127
|
+
throw new WorkflowError('STATE_CORRUPT', 'Remediation event failureKind does not match the referenced strict review decision.', {
|
|
2128
|
+
taskId: task.id,
|
|
2129
|
+
stepId: event.stepId,
|
|
2130
|
+
eventId: event.eventId,
|
|
2131
|
+
failureKind: event.failureKind,
|
|
2132
|
+
reviewDecision: reviewEvent.decision,
|
|
2133
|
+
});
|
|
2134
|
+
}
|
|
2135
|
+
const expectedCauses = (reviewEvent.remediationCauses ?? []).map((cause) => ({
|
|
2136
|
+
authority: 'external-reviewer' as const,
|
|
2137
|
+
causeId: cause.causeId,
|
|
2138
|
+
causeFingerprint: cause.causeFingerprint,
|
|
2139
|
+
planConflict: cause.planConflict,
|
|
2140
|
+
}));
|
|
2141
|
+
if (canonicalJsonStringify(event.causeBindings ?? []) !== canonicalJsonStringify(expectedCauses)) {
|
|
2142
|
+
throw new WorkflowError('STATE_CORRUPT', 'Remediation reviewer causes do not match the exact strict review event.', {
|
|
2143
|
+
taskId: task.id,
|
|
2144
|
+
stepId: event.stepId,
|
|
2145
|
+
eventId: event.eventId,
|
|
2146
|
+
reviewEventId: reviewEvent.eventId,
|
|
2147
|
+
});
|
|
2148
|
+
}
|
|
2149
|
+
}
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
function validateCorrectiveDecisionCoverage(
|
|
2153
|
+
store: FileStateStore,
|
|
2154
|
+
task: TaskState,
|
|
2155
|
+
decisions: readonly CorrectiveDecisionEvent[],
|
|
2156
|
+
remediationEvents: readonly RemediationEvent[],
|
|
2157
|
+
): void {
|
|
2158
|
+
const remediationEventsById = new Map(remediationEvents.map((event) => [event.eventId, event]));
|
|
2159
|
+
const decisionBindings = new Set<string>();
|
|
2160
|
+
for (const decision of decisions) {
|
|
2161
|
+
const binding = `${decision.stepId}\0${decision.triggeringAttemptCount}`;
|
|
2162
|
+
if (decisionBindings.has(binding)) {
|
|
2163
|
+
throw new WorkflowError('STATE_CORRUPT', 'Duplicate corrective decisions exist for the same Step and triggering ordinal.', {
|
|
2164
|
+
taskId: task.id,
|
|
2165
|
+
stepId: decision.stepId,
|
|
2166
|
+
triggeringAttemptCount: decision.triggeringAttemptCount,
|
|
2167
|
+
});
|
|
2168
|
+
}
|
|
2169
|
+
decisionBindings.add(binding);
|
|
2170
|
+
const priorEvents = remediationEvents
|
|
2171
|
+
.filter((event) => event.stepId === decision.stepId && event.attemptOrdinal < decision.triggeringAttemptCount)
|
|
2172
|
+
.sort((left, right) => left.attemptOrdinal - right.attemptOrdinal);
|
|
2173
|
+
const coveredEventIds = normalizeStringList(decision.coveredEventIds, 'Corrective decision coveredEventIds');
|
|
2174
|
+
if (decision.triggeringAttemptCount !== priorEvents.length + 1) {
|
|
2175
|
+
throw new WorkflowError('STATE_CORRUPT', 'Corrective decision triggeringAttemptCount must equal the blocked next attempt ordinal.', {
|
|
2176
|
+
taskId: task.id,
|
|
2177
|
+
stepId: decision.stepId,
|
|
2178
|
+
decisionEventId: decision.eventId,
|
|
2179
|
+
triggeringAttemptCount: decision.triggeringAttemptCount,
|
|
2180
|
+
priorAttemptCount: priorEvents.length,
|
|
2181
|
+
});
|
|
2182
|
+
}
|
|
2183
|
+
if (!sameStringArray(coveredEventIds, priorEvents.map((event) => event.eventId))) {
|
|
2184
|
+
throw new WorkflowError('STATE_CORRUPT', 'Corrective decision coveredEventIds must exactly cover prior remediation events for that Step binding.', {
|
|
2185
|
+
taskId: task.id,
|
|
2186
|
+
stepId: decision.stepId,
|
|
2187
|
+
decisionEventId: decision.eventId,
|
|
2188
|
+
coveredEventIds,
|
|
2189
|
+
expectedCoveredEventIds: priorEvents.map((event) => event.eventId),
|
|
2190
|
+
});
|
|
2191
|
+
}
|
|
2192
|
+
for (const eventId of coveredEventIds) {
|
|
2193
|
+
const linked = remediationEventsById.get(eventId);
|
|
2194
|
+
if (!linked || linked.stepId !== decision.stepId) {
|
|
2195
|
+
throw new WorkflowError('STATE_CORRUPT', 'Corrective decision coveredEventIds references an invalid remediation event.', {
|
|
2196
|
+
taskId: task.id,
|
|
2197
|
+
stepId: decision.stepId,
|
|
2198
|
+
decisionEventId: decision.eventId,
|
|
2199
|
+
eventId,
|
|
2200
|
+
});
|
|
2201
|
+
}
|
|
2202
|
+
}
|
|
2203
|
+
if (decision.recoveryBasis === 'plan-integrity') {
|
|
2204
|
+
const evidence = decision.planIntegrityEvidence;
|
|
2205
|
+
const bindings = evidence?.kind === 'missing-npm-script-outside-allowed-writes'
|
|
2206
|
+
? [{ eventId: evidence.remediationEventId, eventHash: evidence.remediationEventHash, attemptOrdinal: 1 }]
|
|
2207
|
+
: evidence?.remediationEvents ?? [];
|
|
2208
|
+
const support = evidence?.kind === 'check-support-anchor-outside-allowed-writes'
|
|
2209
|
+
|| evidence?.kind === 'failed-check-component-owner-outside-allowed-writes';
|
|
2210
|
+
const scoped = support && bindings[0]?.attemptOrdinal !== 1
|
|
2211
|
+
? selectPlanIntegrityRemediations(store, { ...task, planHash: decision.planHash, revision: evidence.taskRevision }, priorEvents,
|
|
2212
|
+
decisions.filter(d => d.stepId === decision.stepId && d.triggeringAttemptCount < decision.triggeringAttemptCount), task.planHash)
|
|
2213
|
+
: priorEvents;
|
|
2214
|
+
if (!evidence || !scoped
|
|
2215
|
+
|| bindings.length !== scoped.length
|
|
2216
|
+
|| scoped.some((linked, index) => linked.failureKind !== 'checks-failed'
|
|
2217
|
+
|| linked.eventId !== bindings[index]?.eventId
|
|
2218
|
+
|| linked.eventHash !== bindings[index]?.eventHash
|
|
2219
|
+
|| linked.attemptOrdinal !== bindings[index]?.attemptOrdinal)) {
|
|
2220
|
+
throw new WorkflowError('STATE_CORRUPT', 'Plan-integrity recovery must bind the exact checks-failed remediation history.', {
|
|
2221
|
+
taskId: task.id,
|
|
2222
|
+
stepId: decision.stepId,
|
|
2223
|
+
decisionEventId: decision.eventId,
|
|
2224
|
+
});
|
|
2225
|
+
}
|
|
2226
|
+
if (evidence.kind === 'failed-check-component-owner-outside-allowed-writes'
|
|
2227
|
+
&& !priorEvents.some((linked) => linked.causeBindings?.some((cause) =>
|
|
2228
|
+
cause.authority === 'mechanical'
|
|
2229
|
+
&& cause.causeFingerprint === evidence.failedCheckCauseFingerprint))) {
|
|
2230
|
+
throw new WorkflowError('STATE_CORRUPT', 'Component-owner recovery must bind a machine-derived failed-check cause.', {
|
|
2231
|
+
taskId: task.id,
|
|
2232
|
+
stepId: decision.stepId,
|
|
2233
|
+
decisionEventId: decision.eventId,
|
|
2234
|
+
});
|
|
2235
|
+
}
|
|
2236
|
+
}
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2239
|
+
|
|
2240
|
+
function validateHistoricalRemediationModes(
|
|
2241
|
+
store: FileStateStore,
|
|
2242
|
+
task: TaskState,
|
|
2243
|
+
stepId: string,
|
|
2244
|
+
remediationEvents: readonly RemediationEvent[],
|
|
2245
|
+
correctiveDecisions: readonly CorrectiveDecisionEvent[],
|
|
2246
|
+
recoveredEventIds: ReadonlySet<string>,
|
|
2247
|
+
overriddenStopDecisionEventIds: ReadonlySet<string>,
|
|
2248
|
+
): void {
|
|
2249
|
+
const causeAwareHistory = remediationEvents.length > 0
|
|
2250
|
+
&& remediationEvents.every((event) => event.causeBindings !== undefined);
|
|
2251
|
+
if (causeAwareHistory) {
|
|
2252
|
+
const audits = readPlanRiskAuditEvents(store, task);
|
|
2253
|
+
const reviews = readStepReviewEvents(store, task);
|
|
2254
|
+
const taskRoot = store.taskRoot(task.projectId, task.id);
|
|
2255
|
+
const cycleBindings = task.planHash
|
|
2256
|
+
? remediationEvents.map((event) =>
|
|
2257
|
+
resolveRemediationPlanCycleBinding(taskRoot, task.planHash!, event, reviews, audits))
|
|
2258
|
+
: remediationEvents.map(() => null);
|
|
2259
|
+
if (cycleBindings.every((binding) => binding !== null)) {
|
|
2260
|
+
let cycleStart = 0;
|
|
2261
|
+
for (let index = 0; index < remediationEvents.length; index += 1) {
|
|
2262
|
+
if (index > 0 && cycleBindings[index]!.semanticHash !== cycleBindings[index - 1]!.semanticHash) {
|
|
2263
|
+
cycleStart = index;
|
|
2264
|
+
}
|
|
2265
|
+
const cyclePrefix = remediationEvents.slice(cycleStart, index + 1);
|
|
2266
|
+
const priorCycles = remediationEvents.slice(0, cycleStart);
|
|
2267
|
+
if (deriveRollingCauseDecision(cyclePrefix, priorCycles) === 'root-cause-replan'
|
|
2268
|
+
&& index < remediationEvents.length - 1
|
|
2269
|
+
&& cycleBindings[index + 1]!.semanticHash === cycleBindings[index]!.semanticHash) {
|
|
2270
|
+
throw new WorkflowError('STATE_CORRUPT', 'Remediation continued after an authoritative cause required semantic replan.', {
|
|
2271
|
+
taskId: task.id,
|
|
2272
|
+
stepId,
|
|
2273
|
+
eventId: remediationEvents[index]!.eventId,
|
|
2274
|
+
});
|
|
2275
|
+
}
|
|
2276
|
+
}
|
|
2277
|
+
return;
|
|
2278
|
+
}
|
|
2279
|
+
for (let index = 0; index < remediationEvents.length; index += 1) {
|
|
2280
|
+
const prefix = remediationEvents.slice(0, index + 1);
|
|
2281
|
+
if (deriveRollingCauseDecision(prefix) === 'root-cause-replan' && index < remediationEvents.length - 1) {
|
|
2282
|
+
throw new WorkflowError('STATE_CORRUPT', 'Remediation continued after an authoritative repeated cause required replan.', {
|
|
2283
|
+
taskId: task.id,
|
|
2284
|
+
stepId,
|
|
2285
|
+
eventId: remediationEvents[index]!.eventId,
|
|
2286
|
+
});
|
|
2287
|
+
}
|
|
2288
|
+
}
|
|
2289
|
+
return;
|
|
2290
|
+
}
|
|
2291
|
+
for (const event of remediationEvents) {
|
|
2292
|
+
if (event.attemptOrdinal <= 2) {
|
|
2293
|
+
if (event.mode !== 'ordinary') {
|
|
2294
|
+
throw new WorkflowError('STATE_CORRUPT', 'The first two remediation attempts must remain ordinary.', {
|
|
2295
|
+
taskId: task.id,
|
|
2296
|
+
stepId,
|
|
2297
|
+
eventId: event.eventId,
|
|
2298
|
+
attemptOrdinal: event.attemptOrdinal,
|
|
2299
|
+
mode: event.mode,
|
|
2300
|
+
});
|
|
2301
|
+
}
|
|
2302
|
+
continue;
|
|
2303
|
+
}
|
|
2304
|
+
if (event.mode === 'ordinary' && !recoveredEventIds.has(event.eventId)) {
|
|
2305
|
+
throw new WorkflowError('STATE_CORRUPT', 'Guarded remediation attempt ordinals above 2 must not remain ordinary.', {
|
|
2306
|
+
taskId: task.id,
|
|
2307
|
+
stepId,
|
|
2308
|
+
eventId: event.eventId,
|
|
2309
|
+
attemptOrdinal: event.attemptOrdinal,
|
|
2310
|
+
});
|
|
2311
|
+
}
|
|
2312
|
+
const priorEvents = remediationEvents
|
|
2313
|
+
.filter((candidate) => candidate.stepId === stepId && candidate.attemptOrdinal < event.attemptOrdinal)
|
|
2314
|
+
.sort((left, right) => left.attemptOrdinal - right.attemptOrdinal);
|
|
2315
|
+
const matching = correctiveDecisions.filter((decision) =>
|
|
2316
|
+
decision.taskId === task.id
|
|
2317
|
+
&& decision.stepId === stepId
|
|
2318
|
+
&& decision.triggeringAttemptCount === event.attemptOrdinal
|
|
2319
|
+
&& (
|
|
2320
|
+
decision.decision === 'continue-fix'
|
|
2321
|
+
|| decision.decision === 'replan-required'
|
|
2322
|
+
|| (decision.decision === 'stop-escalate' && overriddenStopDecisionEventIds.has(decision.eventId))
|
|
2323
|
+
)
|
|
2324
|
+
&& sameStringArray(decision.coveredEventIds, priorEvents.map((candidate) => candidate.eventId)),
|
|
2325
|
+
);
|
|
2326
|
+
if (matching.length > 1) {
|
|
2327
|
+
throw new WorkflowError('STATE_CORRUPT', 'Corrective remediation mode has conflicting decisions for the exact attempt ordinal.', {
|
|
2328
|
+
taskId: task.id,
|
|
2329
|
+
stepId,
|
|
2330
|
+
eventId: event.eventId,
|
|
2331
|
+
attemptOrdinal: event.attemptOrdinal,
|
|
2332
|
+
matchingDecisionIds: matching.map((decision) => decision.eventId),
|
|
2333
|
+
});
|
|
2334
|
+
}
|
|
2335
|
+
}
|
|
2336
|
+
}
|
|
2337
|
+
|
|
2338
|
+
function validateHistoricalRemediationModesForAllSteps(
|
|
2339
|
+
store: FileStateStore,
|
|
2340
|
+
task: TaskState,
|
|
2341
|
+
remediationEvents: readonly RemediationEvent[],
|
|
2342
|
+
correctiveDecisions: readonly CorrectiveDecisionEvent[],
|
|
2343
|
+
recoveredEventIds: ReadonlySet<string>,
|
|
2344
|
+
overriddenStopDecisionEventIds: ReadonlySet<string>,
|
|
2345
|
+
): void {
|
|
2346
|
+
const stepIds = new Set(remediationEvents.map((event) => event.stepId));
|
|
2347
|
+
for (const stepId of stepIds) {
|
|
2348
|
+
const stepEvents = remediationEvents
|
|
2349
|
+
.filter((event) => event.stepId === stepId)
|
|
2350
|
+
.sort((left, right) => left.attemptOrdinal - right.attemptOrdinal);
|
|
2351
|
+
validateHistoricalRemediationModes(
|
|
2352
|
+
store,
|
|
2353
|
+
task,
|
|
2354
|
+
stepId,
|
|
2355
|
+
stepEvents,
|
|
2356
|
+
correctiveDecisions,
|
|
2357
|
+
recoveredEventIds,
|
|
2358
|
+
overriddenStopDecisionEventIds,
|
|
2359
|
+
);
|
|
2360
|
+
}
|
|
2361
|
+
}
|
|
2362
|
+
|
|
2363
|
+
function validateCorrectiveDecisionAudit(audit: CorrectivePlanAuditInput): void {
|
|
2364
|
+
if (!audit.auditor.trim() || !audit.summary.trim()) {
|
|
2365
|
+
throw new WorkflowError('INVALID_ARGUMENT', 'Corrective decision requires auditor and summary.');
|
|
2366
|
+
}
|
|
2367
|
+
if (!CORRECTIVE_DECISION_VALUES.has(audit.decision)) {
|
|
2368
|
+
throw new WorkflowError('INVALID_ARGUMENT', 'Corrective decision value is not supported.', {
|
|
2369
|
+
decision: audit.decision,
|
|
2370
|
+
});
|
|
2371
|
+
}
|
|
2372
|
+
normalizeStringList(audit.reviewedFindingIds, 'Corrective decision reviewedFindingIds');
|
|
2373
|
+
}
|
|
2374
|
+
|
|
2375
|
+
function normalizeStringList(values: readonly string[], label: string): string[] {
|
|
2376
|
+
if (!Array.isArray(values)) {
|
|
2377
|
+
throw new WorkflowError('INVALID_ARGUMENT', `${label} must be an array.`);
|
|
2378
|
+
}
|
|
2379
|
+
const normalized = values.map((value) => {
|
|
2380
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
2381
|
+
throw new WorkflowError('INVALID_ARGUMENT', `${label} must contain non-empty strings.`);
|
|
2382
|
+
}
|
|
2383
|
+
return value.trim();
|
|
2384
|
+
});
|
|
2385
|
+
if (new Set(normalized).size !== normalized.length) {
|
|
2386
|
+
throw new WorkflowError('INVALID_ARGUMENT', `${label} must not contain duplicates.`);
|
|
2387
|
+
}
|
|
2388
|
+
return normalized;
|
|
2389
|
+
}
|
|
2390
|
+
|
|
2391
|
+
function remediationEventMatchesFailure(
|
|
2392
|
+
event: RemediationEvent,
|
|
2393
|
+
failureKind: RemediationFailureKind,
|
|
2394
|
+
failureEvidence: RemediationFailureEvidence,
|
|
2395
|
+
): boolean {
|
|
2396
|
+
if (event.failureKind !== failureKind) return false;
|
|
2397
|
+
const eventExecutionRevision = event.failureEvidence.executionTaskRevision;
|
|
2398
|
+
const candidateExecutionRevision = failureEvidence.executionTaskRevision;
|
|
2399
|
+
if (eventExecutionRevision !== undefined || candidateExecutionRevision !== undefined) {
|
|
2400
|
+
return eventExecutionRevision !== undefined
|
|
2401
|
+
&& candidateExecutionRevision !== undefined
|
|
2402
|
+
&& eventExecutionRevision === candidateExecutionRevision;
|
|
2403
|
+
}
|
|
2404
|
+
return event.failureEvidence.completionCommit === failureEvidence.completionCommit
|
|
2405
|
+
&& event.failureEvidence.checksHash === failureEvidence.checksHash
|
|
2406
|
+
&& event.failureEvidence.reviewEventHash === failureEvidence.reviewEventHash
|
|
2407
|
+
&& event.failureEvidence.reviewerAttestationHash === failureEvidence.reviewerAttestationHash;
|
|
2408
|
+
}
|
|
2409
|
+
|
|
2410
|
+
function remediationFailureIdentityKey(
|
|
2411
|
+
failureKind: RemediationFailureKind,
|
|
2412
|
+
failureEvidence: RemediationFailureEvidence,
|
|
2413
|
+
): string {
|
|
2414
|
+
if (failureEvidence.executionTaskRevision !== undefined) {
|
|
2415
|
+
return ['execution', failureKind, String(failureEvidence.executionTaskRevision)].join('\0');
|
|
2416
|
+
}
|
|
2417
|
+
return [
|
|
2418
|
+
failureKind,
|
|
2419
|
+
failureEvidence.completionCommit,
|
|
2420
|
+
failureEvidence.checksHash,
|
|
2421
|
+
failureEvidence.reviewEventHash ?? '',
|
|
2422
|
+
failureEvidence.reviewerAttestationHash ?? '',
|
|
2423
|
+
].join('\0');
|
|
2424
|
+
}
|
|
2425
|
+
|
|
2426
|
+
function sameStringArray(left: readonly string[], right: readonly string[]): boolean {
|
|
2427
|
+
return left.length === right.length && left.every((value, index) => value === right[index]);
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2430
|
+
function assertCommitSha(value: string, label: string, file: string, line: number): void {
|
|
2431
|
+
if (!COMMIT_SHA_PATTERN.test(value)) {
|
|
2432
|
+
throw new WorkflowError('STATE_CORRUPT', `${label} must be a full lowercase Git commit SHA.`, {
|
|
2433
|
+
file,
|
|
2434
|
+
line,
|
|
2435
|
+
value,
|
|
2436
|
+
});
|
|
2437
|
+
}
|
|
2438
|
+
}
|