codex-workflow-v2 2.0.0-beta.12.9 → 2.0.0-beta.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/README.md +58 -16
  2. package/dist/reviewer-runtime-build.json +1092 -0
  3. package/dist/src/alpha6/check-support-anchor.d.ts +24 -0
  4. package/dist/src/alpha6/check-support-anchor.js +102 -0
  5. package/dist/src/alpha6/check-support-anchor.js.map +1 -0
  6. package/dist/src/alpha6/component-owner.d.ts +28 -0
  7. package/dist/src/alpha6/component-owner.js +95 -0
  8. package/dist/src/alpha6/component-owner.js.map +1 -0
  9. package/dist/src/alpha6/downstream-proof.js +4 -2
  10. package/dist/src/alpha6/downstream-proof.js.map +1 -1
  11. package/dist/src/alpha6/mechanical-feasibility.js +42 -12
  12. package/dist/src/alpha6/mechanical-feasibility.js.map +1 -1
  13. package/dist/src/alpha6/milestone.d.ts +6 -2
  14. package/dist/src/alpha6/milestone.js +164 -10
  15. package/dist/src/alpha6/milestone.js.map +1 -1
  16. package/dist/src/alpha6/npm-check-contract.d.ts +11 -0
  17. package/dist/src/alpha6/npm-check-contract.js +36 -0
  18. package/dist/src/alpha6/npm-check-contract.js.map +1 -0
  19. package/dist/src/alpha6/plan-integrity.js +157 -27
  20. package/dist/src/alpha6/plan-integrity.js.map +1 -1
  21. package/dist/src/alpha6/plan-risk.js +3 -1
  22. package/dist/src/alpha6/plan-risk.js.map +1 -1
  23. package/dist/src/alpha6/remediation-cause.d.ts +13 -0
  24. package/dist/src/alpha6/remediation-cause.js +77 -0
  25. package/dist/src/alpha6/remediation-cause.js.map +1 -0
  26. package/dist/src/alpha6/remediation.d.ts +15 -4
  27. package/dist/src/alpha6/remediation.js +430 -67
  28. package/dist/src/alpha6/remediation.js.map +1 -1
  29. package/dist/src/alpha6/review.js +34 -4
  30. package/dist/src/alpha6/review.js.map +1 -1
  31. package/dist/src/artifacts.js +6 -0
  32. package/dist/src/artifacts.js.map +1 -1
  33. package/dist/src/cli-actions.d.ts +25 -0
  34. package/dist/src/cli-actions.js +109 -0
  35. package/dist/src/cli-actions.js.map +1 -0
  36. package/dist/src/cli.js +216 -258
  37. package/dist/src/cli.js.map +1 -1
  38. package/dist/src/contracts.d.ts +166 -5
  39. package/dist/src/credential-transport.d.ts +38 -0
  40. package/dist/src/credential-transport.js +156 -0
  41. package/dist/src/credential-transport.js.map +1 -0
  42. package/dist/src/dependency-provenance.d.ts +23 -2
  43. package/dist/src/dependency-provenance.js +221 -34
  44. package/dist/src/dependency-provenance.js.map +1 -1
  45. package/dist/src/domain/completed-step-carryover.d.ts +13 -0
  46. package/dist/src/domain/completed-step-carryover.js +71 -0
  47. package/dist/src/domain/completed-step-carryover.js.map +1 -0
  48. package/dist/src/domain/validation.d.ts +9 -1
  49. package/dist/src/domain/validation.js +86 -0
  50. package/dist/src/domain/validation.js.map +1 -1
  51. package/dist/src/gateway-handshake.d.ts +11 -0
  52. package/dist/src/gateway-handshake.js +92 -0
  53. package/dist/src/gateway-handshake.js.map +1 -0
  54. package/dist/src/index.d.ts +2 -0
  55. package/dist/src/index.js +2 -0
  56. package/dist/src/index.js.map +1 -1
  57. package/dist/src/lifecycle/corrective-replan-binding-manifest.d.ts +5 -0
  58. package/dist/src/lifecycle/corrective-replan-binding-manifest.js +9 -0
  59. package/dist/src/lifecycle/corrective-replan-binding-manifest.js.map +1 -1
  60. package/dist/src/lifecycle/corrective-replan.js +1 -1
  61. package/dist/src/lifecycle/corrective-replan.js.map +1 -1
  62. package/dist/src/lifecycle/semantic-registry.js +1 -1
  63. package/dist/src/observation.js +1 -2
  64. package/dist/src/observation.js.map +1 -1
  65. package/dist/src/observed-routes.d.ts +114 -0
  66. package/dist/src/observed-routes.js +545 -0
  67. package/dist/src/observed-routes.js.map +1 -0
  68. package/dist/src/operational-contract.d.ts +26 -0
  69. package/dist/src/operational-contract.js +94 -0
  70. package/dist/src/operational-contract.js.map +1 -0
  71. package/dist/src/reviewer.d.ts +218 -8
  72. package/dist/src/reviewer.js +927 -25
  73. package/dist/src/reviewer.js.map +1 -1
  74. package/dist/src/state/corrective-replan-executor.js +18 -15
  75. package/dist/src/state/corrective-replan-executor.js.map +1 -1
  76. package/dist/src/state/corrective-replan-public.js +35 -10
  77. package/dist/src/state/corrective-replan-public.js.map +1 -1
  78. package/dist/src/state/corrective-replan-transaction.js +12 -4
  79. package/dist/src/state/corrective-replan-transaction.js.map +1 -1
  80. package/dist/src/state/corrective-yield-transaction.js +11 -2
  81. package/dist/src/state/corrective-yield-transaction.js.map +1 -1
  82. package/dist/src/state/lock.d.ts +1 -0
  83. package/dist/src/state/lock.js +24 -0
  84. package/dist/src/state/lock.js.map +1 -1
  85. package/dist/src/version.d.ts +1 -1
  86. package/dist/src/version.js +1 -1
  87. package/dist/src/version.js.map +1 -1
  88. package/dist/src/workflow.d.ts +65 -4
  89. package/dist/src/workflow.js +1704 -155
  90. package/dist/src/workflow.js.map +1 -1
  91. package/docs/autonomy-guardrails.md +41 -14
  92. package/docs/delegated-approval.md +6 -1
  93. package/docs/development-flow.md +42 -12
  94. package/docs/pdf/codex-workflow-v2-architecture-ru.pdf +0 -0
  95. package/docs/pdf/codex-workflow-v2-chat-only-guide-ru.pdf +0 -0
  96. package/docs/pdf/codex-workflow-v2-technical-reference-ru.pdf +0 -0
  97. package/docs/pdf/sources/codex-workflow-v2-architecture-ru.md +181 -22
  98. package/docs/pdf/sources/codex-workflow-v2-chat-only-guide-ru.md +148 -26
  99. package/docs/pdf/sources/codex-workflow-v2-technical-reference-ru.md +326 -52
  100. package/docs/problem-briefs/01-pre-implementation-integrity.md +11 -7
  101. package/docs/problem-briefs/04-task-dependency-and-structural-replacement-integrity.md +13 -6
  102. package/docs/problem-briefs/README.md +11 -11
  103. package/docs/release.md +83 -9
  104. package/docs/updating-existing-project.md +205 -0
  105. package/docs/validation-report.md +156 -99
  106. package/package.json +12 -3
  107. package/plugins/codex-workflow-gateway/skills/codex-workflow-gateway/SKILL.md +42 -1
  108. package/schemas/authorization-event.schema.json +10 -3
  109. package/schemas/corrective-decision-event.schema.json +164 -9
  110. package/schemas/corrective-replan-credentials.private.schema.json +4 -2
  111. package/schemas/milestone-scope-change-event.schema.json +25 -1
  112. package/schemas/remediation-event.schema.json +17 -1
  113. package/schemas/review-result.schema.json +1 -0
  114. package/schemas/step-review-event.schema.json +15 -0
  115. package/schemas/task.schema.json +61 -1
  116. package/schemas/transition-definition.schema.json +8 -0
@@ -1,12 +1,15 @@
1
1
  import { closeSync, existsSync, openSync, readFileSync, unlinkSync } from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { pathAllowed } from '../domain/validation.js';
3
4
  import { WorkflowError } from '../errors.js';
4
5
  import { hashCanonical } from '../lifecycle/fingerprint.js';
5
6
  import { createUlid } from '../ulid.js';
6
7
  import { PACKAGE_VERSION } from '../version.js';
7
- import { assertHash64, canonicalJsonStringify, sha256Hex } from './store-sidecars.js';
8
+ import { assertHash64, canonicalJsonStringify, prepareSidecarAppend, sha256Hex, } from './store-sidecars.js';
8
9
  import { readTaskC1Posture } from './handoff.js';
9
10
  import { buildCurrentStrictStepReviewCycle, latestPassedVerifiedStepReviewEvent, readReviewerAttestations, readStepReviewEvents, } from './review.js';
11
+ import { semanticBoundaryHash } from './component-owner.js';
12
+ import { reviewerCauseFingerprint } from './remediation-cause.js';
10
13
  export const REMEDIATION_EVENTS_SIDECAR = 'remediation-events.jsonl';
11
14
  export const CORRECTIVE_DECISIONS_SIDECAR = 'corrective-decisions.jsonl';
12
15
  export const CORRECTIVE_DECISION_RECOVERIES_SIDECAR = 'corrective-decision-recoveries.jsonl';
@@ -146,9 +149,18 @@ export function stopEscalateOverrideDecisionEventIds(store, task) {
146
149
  validateCorrectiveDecisionCoverage(task, decisions, remediations);
147
150
  return validateStopEscalateOverrideHistory(task, remediations, decisions, overrides);
148
151
  }
149
- export function appendRemediationEvent(store, task, stepId, failureKind, failureEvidence, planRiskAudit, now, reboundStopOverrideDecisionEventId = null) {
152
+ export function appendRemediationEvent(store, task, stepId, failureKind, failureEvidence, planRiskAudit, now, reboundStopOverrideDecisionEventId = null, causeBindings = []) {
153
+ const prepared = prepareRemediationEvent(store, task, stepId, failureKind, failureEvidence, planRiskAudit, now, reboundStopOverrideDecisionEventId, causeBindings);
154
+ if (prepared.postimage === null)
155
+ return prepared.event;
156
+ return store.appendSidecarEvent(store.taskRoot(task.projectId, task.id), REMEDIATION_EVENTS_SIDECAR, prepared.event, {
157
+ validateEvent: (event, context) => validateStoredRemediationEvent(event, task, context.file, context.line),
158
+ });
159
+ }
160
+ export function prepareRemediationEvent(store, task, stepId, failureKind, failureEvidence, planRiskAudit, now, reboundStopOverrideDecisionEventId = null, causeBindings = []) {
150
161
  const recoveredDecisionEventId = findCorrectiveRecoveryDecisionIdForFailure(store, task, stepId);
151
- const existing = readRemediationEvents(store, task).filter((event) => event.stepId === stepId);
162
+ const allExisting = readRemediationEvents(store, task);
163
+ const existing = allExisting.filter((event) => event.stepId === stepId);
152
164
  const matching = existing.filter((event) => remediationEventMatchesFailure(event, failureKind, failureEvidence));
153
165
  if (matching.length > 1) {
154
166
  throw new WorkflowError('STATE_CORRUPT', 'Multiple remediation events match the same failure identity.', {
@@ -160,6 +172,14 @@ export function appendRemediationEvent(store, task, stepId, failureKind, failure
160
172
  }
161
173
  if (matching.length === 1) {
162
174
  const reused = matching[0];
175
+ if (canonicalJsonStringify(reused.failureEvidence) !== canonicalJsonStringify(failureEvidence)) {
176
+ throw new WorkflowError('STATE_CONFLICT', 'Remediation failure evidence changed within the same execution identity.', {
177
+ taskId: task.id,
178
+ stepId,
179
+ eventId: reused.eventId,
180
+ executionTaskRevision: failureEvidence.executionTaskRevision ?? null,
181
+ });
182
+ }
163
183
  const priorEvents = existing.filter((event) => event.attemptOrdinal < reused.attemptOrdinal);
164
184
  const gate = deriveAttemptGateFromHistory(task, stepId, planRiskAudit, priorEvents, readCorrectiveDecisionEvents(store, task), {
165
185
  recoveredDecisionEventId,
@@ -177,14 +197,14 @@ export function appendRemediationEvent(store, task, stepId, failureKind, failure
177
197
  actualMode: reused.mode,
178
198
  });
179
199
  }
180
- return reused;
200
+ return { event: reused, postimage: null };
181
201
  }
182
202
  const gate = deriveAttemptGateFromHistory(task, stepId, planRiskAudit, existing, readCorrectiveDecisionEvents(store, task), {
183
203
  recoveredDecisionEventId,
184
204
  reboundStopOverrideDecisionEventId,
185
205
  overriddenStopDecisionEventIds: stopEscalateOverrideDecisionEventIds(store, task),
186
206
  });
187
- return store.appendSidecarEvent(store.taskRoot(task.projectId, task.id), REMEDIATION_EVENTS_SIDECAR, {
207
+ const prepared = prepareSidecarAppend(allExisting, {
188
208
  eventId: `RME-${createUlid(now.getTime())}`,
189
209
  recordedAt: now.toISOString(),
190
210
  taskId: task.id,
@@ -194,9 +214,11 @@ export function appendRemediationEvent(store, task, stepId, failureKind, failure
194
214
  failureEvidence,
195
215
  attemptOrdinal: gate.nextAttemptOrdinal,
196
216
  mode: gate.mode,
217
+ causeBindings: causeBindings.map((binding) => ({ ...binding })),
197
218
  }, {
198
219
  validateEvent: (event, context) => validateStoredRemediationEvent(event, task, context.file, context.line),
199
220
  });
221
+ return { event: prepared.event, postimage: prepared.content };
200
222
  }
201
223
  function findCorrectiveRecoveryDecisionIdForFailure(store, task, stepId) {
202
224
  const remediations = readRawRemediationEvents(store, task).filter((event) => event.stepId === stepId);
@@ -222,7 +244,7 @@ function findCorrectiveRecoveryDecisionIdForFailure(store, task, stepId) {
222
244
  }
223
245
  return recoveries[0]?.correctiveDecisionEventId ?? null;
224
246
  }
225
- export function findRemediationModeRecoveryCandidate(store, task) {
247
+ export function findRemediationModeRecoveryCandidate(store, task, options = {}) {
226
248
  const raw = readRawRemediationEvents(store, task);
227
249
  const candidates = raw.filter((event) => event.attemptOrdinal > 2 && event.mode === 'ordinary');
228
250
  if (candidates.length === 0)
@@ -236,9 +258,9 @@ export function findRemediationModeRecoveryCandidate(store, task) {
236
258
  const posture = readTaskC1Posture(store, task);
237
259
  const lease = readWriterLeaseForModeRecovery(store, task);
238
260
  const actor = posture.state === 'claimed' ? posture.claimant : lease?.owner ?? 'worker';
239
- return assessRemediationModeRecovery(store, task, candidates[0].stepId, actor, lease?.token ?? null);
261
+ return assessRemediationModeRecovery(store, task, candidates[0].stepId, actor, lease?.token ?? null, options);
240
262
  }
241
- export function assessRemediationModeRecovery(store, task, stepId, actor, writerToken) {
263
+ export function assessRemediationModeRecovery(store, task, stepId, actor, writerToken, options = {}) {
242
264
  const normalizedActor = actor.trim();
243
265
  if (!normalizedActor)
244
266
  throw new WorkflowError('INVALID_ARGUMENT', 'Remediation mode recovery actor is required.');
@@ -303,7 +325,9 @@ export function assessRemediationModeRecovery(store, task, stepId, actor, writer
303
325
  });
304
326
  }
305
327
  const correctiveRecovery = correctiveRecoveries[0];
306
- const authority = requireRemediationModeRecoveryAuthority(store, task, normalizedActor, writerToken);
328
+ const authority = options.authorityMode === 'observe'
329
+ ? observeRemediationModeRecoveryAuthority(store, task)
330
+ : requireRemediationModeRecoveryAuthority(store, task, normalizedActor, writerToken);
307
331
  const existing = readRemediationModeRecoveryEvents(store, task).filter((event) => event.remediationEventId === malformed[0].eventId);
308
332
  if (existing.length > 1) {
309
333
  throw new WorkflowError('STATE_CORRUPT', 'Multiple remediation mode recoveries target one event.', {
@@ -353,6 +377,14 @@ export function assessRemediationModeRecovery(store, task, stepId, actor, writer
353
377
  evidenceFingerprint,
354
378
  };
355
379
  }
380
+ function observeRemediationModeRecoveryAuthority(store, task) {
381
+ const posture = readTaskC1Posture(store, task);
382
+ const lease = readWriterLeaseForModeRecovery(store, task);
383
+ return {
384
+ c1ClaimEventHash: posture.state === 'none' ? null : posture.latestEvent.eventHash,
385
+ writerLeaseTokenHash: lease ? sha256Hex(lease.token) : null,
386
+ };
387
+ }
356
388
  export function appendRemediationModeRecovery(store, task, stepId, expectedRevision, actor, writerToken, now) {
357
389
  if (task.revision !== expectedRevision) {
358
390
  throw new WorkflowError('STATE_CONFLICT', `State revision conflict for ${task.id}`, {
@@ -726,18 +758,34 @@ export function appendPlanIntegrityRecoveryDecision(store, task, stepId, auditor
726
758
  throw new WorkflowError('INVALID_ARGUMENT', 'Plan-integrity recovery requires a derived auditor identity.');
727
759
  }
728
760
  const remediationEvents = readRemediationEvents(store, task).filter((event) => event.stepId === stepId);
729
- if (remediationEvents.length !== 1
730
- || remediationEvents[0]?.attemptOrdinal !== 1
731
- || remediationEvents[0]?.failureKind !== 'checks-failed'
732
- || remediationEvents[0]?.eventId !== evidence.remediationEventId
733
- || remediationEvents[0]?.eventHash !== evidence.remediationEventHash) {
734
- throw new WorkflowError('TRANSITION_BLOCKED', 'Plan-integrity recovery requires one exact first checks-failed remediation event.', {
761
+ const expectedBindings = evidence.kind === 'missing-npm-script-outside-allowed-writes'
762
+ ? [{
763
+ eventId: evidence.remediationEventId,
764
+ eventHash: evidence.remediationEventHash,
765
+ attemptOrdinal: 1,
766
+ }]
767
+ : evidence.remediationEvents;
768
+ if (remediationEvents.length !== expectedBindings.length
769
+ || remediationEvents.some((event, index) => {
770
+ const binding = expectedBindings[index];
771
+ return event.attemptOrdinal !== index + 1
772
+ || event.failureKind !== 'checks-failed'
773
+ || event.eventId !== binding?.eventId
774
+ || event.eventHash !== binding.eventHash
775
+ || event.attemptOrdinal !== binding.attemptOrdinal;
776
+ })) {
777
+ throw new WorkflowError('TRANSITION_BLOCKED', 'Plan-integrity recovery requires the exact checks-failed remediation history.', {
735
778
  taskId: task.id,
736
779
  stepId,
737
780
  remediationEventIds: remediationEvents.map((event) => event.eventId),
738
781
  });
739
782
  }
740
- const decisions = readCorrectiveDecisionEvents(store, task).filter((event) => event.stepId === stepId && event.triggeringAttemptCount === 2);
783
+ const triggeringAttemptCount = remediationEvents.length + 1;
784
+ const recoveryDecision = evidence.kind === 'check-support-anchor-outside-allowed-writes'
785
+ || evidence.kind === 'failed-check-component-owner-outside-allowed-writes'
786
+ ? 'continue-fix'
787
+ : 'replan-required';
788
+ const decisions = readCorrectiveDecisionEvents(store, task).filter((event) => event.stepId === stepId && event.triggeringAttemptCount === triggeringAttemptCount);
741
789
  const findingId = `PLAN-INTEGRITY-${evidence.conflictHash.slice(0, 16).toUpperCase()}`;
742
790
  if (decisions.length > 1) {
743
791
  throw new WorkflowError('STATE_CORRUPT', 'Multiple Plan-integrity recovery decisions exist for the same Step.', {
@@ -749,7 +797,7 @@ export function appendPlanIntegrityRecoveryDecision(store, task, stepId, auditor
749
797
  if (decisions.length === 1) {
750
798
  const existing = decisions[0];
751
799
  if (existing.planHash !== task.planHash
752
- || existing.decision !== 'replan-required'
800
+ || existing.decision !== recoveryDecision
753
801
  || existing.auditor !== auditor.trim()
754
802
  || existing.planIntegrityEvidence?.conflictHash !== evidence.conflictHash) {
755
803
  throw new WorkflowError('STATE_CONFLICT', 'Existing Plan-integrity recovery decision does not match the requested conflict.', {
@@ -765,11 +813,11 @@ export function appendPlanIntegrityRecoveryDecision(store, task, stepId, auditor
765
813
  recordedAt: now.toISOString(),
766
814
  taskId: task.id,
767
815
  stepId,
768
- triggeringAttemptCount: 2,
769
- decision: 'replan-required',
816
+ triggeringAttemptCount,
817
+ decision: recoveryDecision,
770
818
  auditor: auditor.trim(),
771
819
  coveredFindingIds: [findingId],
772
- coveredEventIds: [evidence.remediationEventId],
820
+ coveredEventIds: remediationEvents.map((event) => event.eventId),
773
821
  planHash: task.planHash,
774
822
  recoveryBasis: 'plan-integrity',
775
823
  planIntegrityEvidence: evidence,
@@ -854,22 +902,71 @@ function deriveAttemptGateFromHistory(task, stepId, planRiskAudit, remediationEv
854
902
  const attemptCount = remediationEvents.length;
855
903
  const nextAttemptOrdinal = attemptCount + 1;
856
904
  const guardedCategory = requireGuardedCategory(planRiskAudit, stepId);
857
- if (attemptCount < 2) {
858
- const earlyDecision = findCurrentCorrectiveDecision(task, stepId, nextAttemptOrdinal, correctiveDecisions);
859
- if (attemptCount === 1
860
- && earlyDecision?.recoveryBasis === 'plan-integrity'
861
- && earlyDecision.decision === 'replan-required'
862
- && earlyDecision.planHash === task.planHash) {
905
+ const rollingCauseDecision = deriveRollingCauseDecision(remediationEvents);
906
+ const currentCorrectiveDecision = findCurrentCorrectiveDecision(task, stepId, nextAttemptOrdinal, correctiveDecisions);
907
+ const planIntegrityDecision = currentCorrectiveDecision;
908
+ if (planIntegrityDecision?.recoveryBasis === 'plan-integrity'
909
+ && (planIntegrityDecision.planHash === task.planHash || planIntegrityDecision.decision === 'replan-required')) {
910
+ if (planIntegrityDecision.planHash !== task.planHash) {
911
+ return {
912
+ stepId,
913
+ guardedCategory,
914
+ attemptCount,
915
+ nextAttemptOrdinal,
916
+ mode: 'corrective',
917
+ correctiveDecision: planIntegrityDecision,
918
+ posture: planIntegrityDecision.decision === 'replan-required' ? 'replan-ready' : 'continue-fix',
919
+ };
920
+ }
921
+ if (planIntegrityDecision.decision === 'continue-fix'
922
+ && (planIntegrityDecision.planIntegrityEvidence?.kind === 'check-support-anchor-outside-allowed-writes'
923
+ || planIntegrityDecision.planIntegrityEvidence?.kind === 'failed-check-component-owner-outside-allowed-writes')) {
863
924
  return {
864
925
  stepId,
865
926
  guardedCategory,
866
927
  attemptCount,
867
928
  nextAttemptOrdinal,
868
929
  mode: 'ordinary',
869
- correctiveDecision: earlyDecision,
870
- posture: 'replan-required',
930
+ correctiveDecision: planIntegrityDecision,
931
+ posture: 'continue-fix',
871
932
  };
872
933
  }
934
+ if (planIntegrityDecision.decision !== 'replan-required') {
935
+ throw new WorkflowError('STATE_CORRUPT', 'Plan-integrity decision has an unsupported disposition.', {
936
+ taskId: task.id,
937
+ stepId,
938
+ decisionEventId: planIntegrityDecision.eventId,
939
+ });
940
+ }
941
+ return {
942
+ stepId,
943
+ guardedCategory,
944
+ attemptCount,
945
+ nextAttemptOrdinal,
946
+ mode: 'ordinary',
947
+ correctiveDecision: planIntegrityDecision,
948
+ posture: 'replan-required',
949
+ };
950
+ }
951
+ // A recorded corrective decision remains authoritative over an ordinary
952
+ // rolling continuation. A repeated authoritative cause is fresh evidence
953
+ // that the current Plan itself is no longer viable and still forces replan.
954
+ if (rollingCauseDecision !== null
955
+ && !(rollingCauseDecision === 'same-task-fix' && currentCorrectiveDecision)) {
956
+ if (rollingCauseDecision === 'root-cause-replan' && options.enforceRuntimeGate) {
957
+ throw new WorkflowError('TRANSITION_BLOCKED', `Guarded Step ${stepId} repeated the same authoritative cause after a claimed fix and requires root-cause replan.`, { taskId: task.id, stepId, attemptCount, requiredAction: 'task plan-set' });
958
+ }
959
+ return {
960
+ stepId,
961
+ guardedCategory,
962
+ attemptCount,
963
+ nextAttemptOrdinal,
964
+ mode: 'ordinary',
965
+ correctiveDecision: null,
966
+ posture: rollingCauseDecision === 'root-cause-replan' ? 'replan-required' : 'ordinary',
967
+ };
968
+ }
969
+ if (attemptCount < 2) {
873
970
  return {
874
971
  stepId,
875
972
  guardedCategory,
@@ -880,7 +977,7 @@ function deriveAttemptGateFromHistory(task, stepId, planRiskAudit, remediationEv
880
977
  posture: 'ordinary',
881
978
  };
882
979
  }
883
- const correctiveDecision = findCurrentCorrectiveDecision(task, stepId, nextAttemptOrdinal, correctiveDecisions);
980
+ const correctiveDecision = currentCorrectiveDecision;
884
981
  if (!correctiveDecision) {
885
982
  return {
886
983
  stepId,
@@ -974,6 +1071,16 @@ function deriveAttemptGateFromHistory(task, stepId, planRiskAudit, remediationEv
974
1071
  posture: correctiveDecision.decision === 'continue-fix' ? 'continue-fix' : correctiveDecision.decision,
975
1072
  };
976
1073
  }
1074
+ export function deriveRollingCauseDecision(remediationEvents) {
1075
+ if (remediationEvents.length === 0 || remediationEvents.some((event) => event.causeBindings === undefined))
1076
+ return null;
1077
+ const latest = remediationEvents.at(-1);
1078
+ const priorFingerprints = new Set(remediationEvents.slice(0, -1)
1079
+ .flatMap((event) => event.causeBindings.map((cause) => cause.causeFingerprint)));
1080
+ const authoritativeRepeat = latest.causeBindings.some((cause) => priorFingerprints.has(cause.causeFingerprint)
1081
+ && (cause.authority === 'mechanical' || (cause.authority === 'external-reviewer' && cause.planConflict)));
1082
+ return authoritativeRepeat ? 'root-cause-replan' : 'same-task-fix';
1083
+ }
977
1084
  function findCurrentCorrectiveDecision(task, stepId, triggeringAttemptOrdinal, events) {
978
1085
  const matches = events.filter((event) => event.taskId === task.id
979
1086
  && event.stepId === stepId
@@ -1068,6 +1175,32 @@ function validateStoredRemediationEvent(event, task, file, line) {
1068
1175
  if (event.failureEvidence.reviewerAttestationHash !== null) {
1069
1176
  assertHash64(event.failureEvidence.reviewerAttestationHash, `Remediation failure evidence reviewerAttestationHash at ${file}:${line}`);
1070
1177
  }
1178
+ const hasExecutionRevision = event.failureEvidence.executionTaskRevision !== undefined;
1179
+ const hasDirtyWorktreeHash = event.failureEvidence.dirtyWorktreeHash !== undefined;
1180
+ if (hasExecutionRevision !== hasDirtyWorktreeHash) {
1181
+ throw new WorkflowError('STATE_CORRUPT', 'Remediation execution revision and dirty-worktree hash must be recorded together.', {
1182
+ file,
1183
+ line,
1184
+ taskId: task.id,
1185
+ stepId: event.stepId,
1186
+ eventId: event.eventId,
1187
+ });
1188
+ }
1189
+ if (hasExecutionRevision
1190
+ && (!Number.isInteger(event.failureEvidence.executionTaskRevision)
1191
+ || event.failureEvidence.executionTaskRevision <= 0)) {
1192
+ throw new WorkflowError('STATE_CORRUPT', 'Remediation execution task revision must be a positive integer.', {
1193
+ file,
1194
+ line,
1195
+ taskId: task.id,
1196
+ stepId: event.stepId,
1197
+ eventId: event.eventId,
1198
+ executionTaskRevision: event.failureEvidence.executionTaskRevision,
1199
+ });
1200
+ }
1201
+ if (hasDirtyWorktreeHash) {
1202
+ assertHash64(event.failureEvidence.dirtyWorktreeHash, `Remediation dirty-worktree hash at ${file}:${line}`);
1203
+ }
1071
1204
  if (!Number.isInteger(event.attemptOrdinal) || event.attemptOrdinal <= 0) {
1072
1205
  throw new WorkflowError('STATE_CORRUPT', 'Remediation event attemptOrdinal must be a positive integer.', {
1073
1206
  file,
@@ -1077,8 +1210,33 @@ function validateStoredRemediationEvent(event, task, file, line) {
1077
1210
  attemptOrdinal: event.attemptOrdinal,
1078
1211
  });
1079
1212
  }
1213
+ if (event.causeBindings !== undefined) {
1214
+ const fingerprints = new Set();
1215
+ for (const cause of event.causeBindings) {
1216
+ if (!['mechanical', 'external-reviewer'].includes(cause.authority)
1217
+ || !cause.causeId?.trim()
1218
+ || !/^[a-f0-9]{64}$/.test(cause.causeFingerprint)
1219
+ || typeof cause.planConflict !== 'boolean'
1220
+ || (cause.authority === 'mechanical'
1221
+ && (cause.planConflict || cause.causeId !== `mechanical:${cause.causeFingerprint}`))
1222
+ || (cause.authority === 'external-reviewer'
1223
+ && reviewerCauseFingerprint(cause.causeId) !== cause.causeFingerprint)) {
1224
+ throw new WorkflowError('STATE_CORRUPT', 'Remediation cause binding is invalid.', {
1225
+ file, line, eventId: event.eventId,
1226
+ });
1227
+ }
1228
+ if (fingerprints.has(cause.causeFingerprint)) {
1229
+ throw new WorkflowError('STATE_CORRUPT', 'Remediation cause fingerprints must be unique per event.', {
1230
+ file, line, eventId: event.eventId,
1231
+ });
1232
+ }
1233
+ fingerprints.add(cause.causeFingerprint);
1234
+ }
1235
+ }
1080
1236
  if (event.failureKind === 'checks-failed') {
1081
- if (event.failureEvidence.reviewEventHash !== null || event.failureEvidence.reviewerAttestationHash !== null) {
1237
+ if (event.failureEvidence.reviewEventHash !== null
1238
+ || event.failureEvidence.reviewerAttestationHash !== null
1239
+ || event.causeBindings?.some((cause) => cause.authority !== 'mechanical')) {
1082
1240
  throw new WorkflowError('STATE_CORRUPT', 'checks-failed remediation events must not reference strict review sidecars.', {
1083
1241
  file,
1084
1242
  line,
@@ -1087,8 +1245,21 @@ function validateStoredRemediationEvent(event, task, file, line) {
1087
1245
  eventId: event.eventId,
1088
1246
  });
1089
1247
  }
1248
+ if (event.causeBindings !== undefined && (!hasExecutionRevision || !hasDirtyWorktreeHash)) {
1249
+ throw new WorkflowError('STATE_CORRUPT', 'Native checks-failed remediation events must bind the declared execution and dirty product bytes.', {
1250
+ file,
1251
+ line,
1252
+ taskId: task.id,
1253
+ stepId: event.stepId,
1254
+ eventId: event.eventId,
1255
+ });
1256
+ }
1090
1257
  }
1091
- else if (event.failureEvidence.reviewEventHash === null || event.failureEvidence.reviewerAttestationHash === null) {
1258
+ else if (event.failureEvidence.reviewEventHash === null
1259
+ || event.failureEvidence.reviewerAttestationHash === null
1260
+ || event.causeBindings?.some((cause) => cause.authority !== 'external-reviewer')
1261
+ || hasExecutionRevision
1262
+ || hasDirtyWorktreeHash) {
1092
1263
  throw new WorkflowError('STATE_CORRUPT', 'review-failed and review-unverified remediation events must reference both strict review sidecars.', {
1093
1264
  file,
1094
1265
  line,
@@ -1139,16 +1310,12 @@ function validateStoredCorrectiveDecisionEvent(event, task, file, line) {
1139
1310
  normalizeStringList(event.coveredFindingIds, 'Corrective decision coveredFindingIds');
1140
1311
  normalizeStringList(event.coveredEventIds, 'Corrective decision coveredEventIds');
1141
1312
  assertHash64(event.planHash, `Corrective decision planHash at ${file}:${line}`);
1142
- if (event.triggeringAttemptCount === 2) {
1313
+ if (event.recoveryBasis === 'plan-integrity' || event.planIntegrityEvidence !== undefined) {
1143
1314
  validatePlanIntegrityDecision(event, task, file, line);
1144
1315
  }
1145
- else if (event.recoveryBasis !== undefined || event.planIntegrityEvidence !== undefined) {
1146
- throw new WorkflowError('STATE_CORRUPT', 'Only the bounded second-attempt replan decision may carry Plan-integrity evidence.', {
1147
- file,
1148
- line,
1149
- taskId: task.id,
1150
- stepId: event.stepId,
1151
- triggeringAttemptCount: event.triggeringAttemptCount,
1316
+ else if (event.triggeringAttemptCount === 2) {
1317
+ throw new WorkflowError('STATE_CORRUPT', 'A second-attempt corrective decision requires bounded Plan-integrity evidence.', {
1318
+ file, line, taskId: task.id, stepId: event.stepId,
1152
1319
  });
1153
1320
  }
1154
1321
  }
@@ -1282,43 +1449,173 @@ function stopEscalateOverrideBindingHash(input) {
1282
1449
  function validatePlanIntegrityDecision(event, task, file, line) {
1283
1450
  const evidence = event.planIntegrityEvidence;
1284
1451
  if (event.recoveryBasis !== 'plan-integrity'
1285
- || event.decision !== 'replan-required'
1286
1452
  || !evidence
1287
- || evidence.kind !== 'missing-npm-script-outside-allowed-writes'
1288
- || evidence.manifestPath !== 'package.json'
1453
+ || (evidence.kind !== 'failed-check-component-owner-outside-allowed-writes'
1454
+ && evidence.manifestPath !== 'package.json')
1289
1455
  || !Number.isInteger(evidence.taskRevision)
1290
1456
  || evidence.taskRevision < 1
1291
1457
  || evidence.taskRevision > task.revision
1292
- || typeof evidence.remediationEventId !== 'string'
1293
- || !evidence.remediationEventId
1294
1458
  || !/^[a-f0-9]{40,64}$/.test(evidence.gitHead)) {
1295
- throw new WorkflowError('STATE_CORRUPT', 'Second-attempt corrective decision is not a valid bounded Plan-integrity recovery.', {
1459
+ throw new WorkflowError('STATE_CORRUPT', 'Corrective decision is not a valid bounded Plan-integrity recovery.', {
1296
1460
  file,
1297
1461
  line,
1298
1462
  taskId: task.id,
1299
1463
  stepId: event.stepId,
1300
1464
  });
1301
1465
  }
1302
- assertHash64(evidence.remediationEventHash, `Plan-integrity remediationEventHash at ${file}:${line}`);
1303
- if (evidence.manifestHash !== null) {
1466
+ if (evidence.kind !== 'failed-check-component-owner-outside-allowed-writes'
1467
+ && evidence.manifestHash !== null) {
1304
1468
  assertHash64(evidence.manifestHash, `Plan-integrity manifestHash at ${file}:${line}`);
1305
1469
  }
1306
1470
  assertHash64(evidence.stepDefinitionHash, `Plan-integrity stepDefinitionHash at ${file}:${line}`);
1307
1471
  assertHash64(evidence.conflictHash, `Plan-integrity conflictHash at ${file}:${line}`);
1308
- const missingScripts = normalizeStringList(evidence.missingScripts, 'Plan-integrity missingScripts');
1309
- const checkCommands = normalizeStringList(evidence.checkCommands, 'Plan-integrity checkCommands');
1310
1472
  const changedFiles = normalizeStringList(evidence.changedFiles, 'Plan-integrity changedFiles');
1311
- if (missingScripts.length === 0 || checkCommands.length === 0) {
1312
- throw new WorkflowError('STATE_CORRUPT', 'Plan-integrity evidence requires at least one missing script and check command.', {
1313
- file,
1314
- line,
1315
- taskId: task.id,
1316
- stepId: event.stepId,
1473
+ let domain;
1474
+ if (evidence.kind === 'missing-npm-script-outside-allowed-writes') {
1475
+ if (event.decision !== 'replan-required'
1476
+ || event.triggeringAttemptCount !== 2
1477
+ || typeof evidence.remediationEventId !== 'string'
1478
+ || !evidence.remediationEventId) {
1479
+ throw new WorkflowError('STATE_CORRUPT', 'Legacy Plan-integrity evidence is valid only for the exact second-attempt boundary.', {
1480
+ file, line, taskId: task.id, stepId: event.stepId,
1481
+ });
1482
+ }
1483
+ assertHash64(evidence.remediationEventHash, `Plan-integrity remediationEventHash at ${file}:${line}`);
1484
+ const missingScripts = normalizeStringList(evidence.missingScripts, 'Plan-integrity missingScripts');
1485
+ const checkCommands = normalizeStringList(evidence.checkCommands, 'Plan-integrity checkCommands');
1486
+ if (missingScripts.length === 0 || checkCommands.length === 0) {
1487
+ throw new WorkflowError('STATE_CORRUPT', 'Plan-integrity evidence requires at least one missing script and check command.', {
1488
+ file, line, taskId: task.id, stepId: event.stepId,
1489
+ });
1490
+ }
1491
+ domain = 'codex-workflow/plan-integrity-conflict/v1';
1492
+ }
1493
+ else if (evidence.kind === 'non-executable-npm-check-outside-allowed-writes') {
1494
+ if (event.decision !== 'replan-required') {
1495
+ throw new WorkflowError('STATE_CORRUPT', 'Plan-integrity v2 evidence requires replan-required.', {
1496
+ file, line, taskId: task.id, stepId: event.stepId,
1497
+ });
1498
+ }
1499
+ if (evidence.remediationEvents.length === 0
1500
+ || event.triggeringAttemptCount !== evidence.remediationEvents.length + 1
1501
+ || evidence.remediationEvents.some((binding, index) => {
1502
+ if (!binding.eventId || binding.attemptOrdinal !== index + 1)
1503
+ return true;
1504
+ assertHash64(binding.eventHash, `Plan-integrity remediation event hash at ${file}:${line}`);
1505
+ return false;
1506
+ })
1507
+ || evidence.conflicts.length === 0
1508
+ || evidence.conflicts.some((conflict) => {
1509
+ if (!['MISSING_NPM_SCRIPT', 'REQUIRED_SELECTOR_MISSING'].includes(conflict.reasonCode)
1510
+ || !conflict.script.trim()
1511
+ || !conflict.command.trim())
1512
+ return true;
1513
+ if (conflict.runnerHash !== null) {
1514
+ assertHash64(conflict.runnerHash, `Plan-integrity runnerHash at ${file}:${line}`);
1515
+ }
1516
+ return conflict.reasonCode === 'MISSING_NPM_SCRIPT'
1517
+ ? conflict.scriptCommand !== null || conflict.runnerPath !== null || conflict.runnerHash !== null
1518
+ : conflict.scriptCommand === null || conflict.runnerPath === null || conflict.runnerHash === null;
1519
+ })) {
1520
+ throw new WorkflowError('STATE_CORRUPT', 'Plan-integrity v2 evidence has invalid remediation or command-conflict bindings.', {
1521
+ file, line, taskId: task.id, stepId: event.stepId,
1522
+ });
1523
+ }
1524
+ domain = 'codex-workflow/plan-integrity-conflict/v2';
1525
+ }
1526
+ else if (evidence.kind === 'check-support-anchor-outside-allowed-writes') {
1527
+ const step = task.steps.find((candidate) => candidate.id === event.stepId) ?? null;
1528
+ const supportPaths = [...new Set(evidence.conflicts.map((conflict) => conflict.supportPath))].sort();
1529
+ const validateCurrentPlanBinding = event.planHash === task.planHash;
1530
+ if (event.decision !== 'continue-fix'
1531
+ || evidence.remediationEvents.length === 0
1532
+ || event.triggeringAttemptCount !== evidence.remediationEvents.length + 1
1533
+ || evidence.remediationEvents.some((binding, index) => {
1534
+ if (!binding.eventId || binding.attemptOrdinal !== index + 1)
1535
+ return true;
1536
+ assertHash64(binding.eventHash, `Plan-integrity remediation event hash at ${file}:${line}`);
1537
+ return false;
1538
+ })
1539
+ || evidence.conflicts.length === 0
1540
+ || !sameStringArray(evidence.scopeExtensions, supportPaths)
1541
+ || (validateCurrentPlanBinding && (!step
1542
+ || evidence.scopeExtensions.some((scopePath) => pathAllowed(scopePath, step.allowedWrites)
1543
+ || pathAllowed(scopePath, step.forbiddenScope))
1544
+ || changedFiles.some((changedPath) => !pathAllowed(changedPath, step.allowedWrites))))
1545
+ || evidence.conflicts.some((conflict) => {
1546
+ assertHash64(conflict.runnerHash, `Plan-integrity runnerHash at ${file}:${line}`);
1547
+ assertHash64(conflict.supportHash, `Plan-integrity supportHash at ${file}:${line}`);
1548
+ return conflict.reasonCode !== 'CHECK_SUPPORT_ANCHOR_OUTSIDE_ALLOWED_WRITES'
1549
+ || !conflict.script.trim()
1550
+ || !conflict.command.trim()
1551
+ || !conflict.scriptCommand.trim()
1552
+ || !conflict.runnerPath.trim()
1553
+ || !conflict.supportPath.trim()
1554
+ || !conflict.importSpecifier.startsWith('.')
1555
+ || normalizeStringList(conflict.expectedOutputPaths, 'Plan-integrity expectedOutputPaths').length === 0
1556
+ || normalizeStringList(conflict.anchoredMigrationNames, 'Plan-integrity anchoredMigrationNames').length === 0
1557
+ || !evidence.scopeExtensions.includes(conflict.supportPath)
1558
+ || (validateCurrentPlanBinding && (!step || conflict.expectedOutputPaths.some((output) => !step.expectedOutputs.some((declared) => (declared.startsWith('path:') ? declared.slice(5) : declared) === output)
1559
+ || !pathAllowed(output, step.allowedWrites))));
1560
+ })) {
1561
+ throw new WorkflowError('STATE_CORRUPT', 'Plan-integrity v3 evidence has invalid check-support bindings.', {
1562
+ file, line, taskId: task.id, stepId: event.stepId,
1563
+ });
1564
+ }
1565
+ domain = 'codex-workflow/plan-integrity-conflict/v3';
1566
+ }
1567
+ else if (evidence.kind === 'failed-check-component-owner-outside-allowed-writes') {
1568
+ const step = task.steps.find((candidate) => candidate.id === event.stepId) ?? null;
1569
+ const ownerPaths = [...new Set(evidence.conflicts.map((conflict) => conflict.ownerPath))].sort();
1570
+ const validateCurrentPlanBinding = event.planHash === task.planHash;
1571
+ assertHash64(evidence.semanticBoundaryHash, `Plan-integrity semanticBoundaryHash at ${file}:${line}`);
1572
+ assertHash64(evidence.failedCheckCauseFingerprint, `Plan-integrity failedCheckCauseFingerprint at ${file}:${line}`);
1573
+ if (event.decision !== 'continue-fix'
1574
+ || evidence.remediationEvents.length === 0
1575
+ || event.triggeringAttemptCount !== evidence.remediationEvents.length + 1
1576
+ || evidence.remediationEvents.some((binding, index) => {
1577
+ if (!binding.eventId || binding.attemptOrdinal !== index + 1)
1578
+ return true;
1579
+ assertHash64(binding.eventHash, `Plan-integrity remediation event hash at ${file}:${line}`);
1580
+ return false;
1581
+ })
1582
+ || evidence.conflicts.length === 0
1583
+ || !sameStringArray(evidence.scopeExtensions, ownerPaths)
1584
+ || (validateCurrentPlanBinding && (!step
1585
+ || evidence.semanticBoundaryHash !== semanticBoundaryHash(task)
1586
+ || evidence.scopeExtensions.some((scopePath) => pathAllowed(scopePath, step.allowedWrites)
1587
+ || pathAllowed(scopePath, step.forbiddenScope))
1588
+ || changedFiles.some((changedPath) => !pathAllowed(changedPath, step.allowedWrites))))
1589
+ || evidence.conflicts.some((conflict) => {
1590
+ assertHash64(conflict.ownerHash, `Plan-integrity component owner hash at ${file}:${line}`);
1591
+ const importChain = normalizeStringList(conflict.importChain, 'Plan-integrity importChain');
1592
+ return conflict.reasonCode !== 'FAILED_CHECK_COMPONENT_OWNER_OUTSIDE_ALLOWED_WRITES'
1593
+ || !conflict.command.trim()
1594
+ || !conflict.runnerPath.trim()
1595
+ || !conflict.ownerPath.trim()
1596
+ || conflict.diagnosticPath !== conflict.ownerPath
1597
+ || !conflict.componentRoot.trim()
1598
+ || !(conflict.ownerPath === conflict.componentRoot
1599
+ || conflict.ownerPath.startsWith(`${conflict.componentRoot}/`))
1600
+ || importChain.length < 2
1601
+ || importChain[0] !== conflict.runnerPath
1602
+ || importChain.at(-1) !== conflict.ownerPath
1603
+ || !evidence.scopeExtensions.includes(conflict.ownerPath);
1604
+ })) {
1605
+ throw new WorkflowError('STATE_CORRUPT', 'Plan-integrity v4 evidence has invalid component-owner bindings.', {
1606
+ file, line, taskId: task.id, stepId: event.stepId,
1607
+ });
1608
+ }
1609
+ domain = 'codex-workflow/plan-integrity-conflict/v4';
1610
+ }
1611
+ else {
1612
+ throw new WorkflowError('STATE_CORRUPT', 'Plan-integrity evidence kind is unsupported.', {
1613
+ file, line, taskId: task.id, stepId: event.stepId,
1317
1614
  });
1318
1615
  }
1319
1616
  const { conflictHash: _conflictHash, ...evidenceBase } = evidence;
1320
1617
  const expectedConflictHash = hashCanonical({
1321
- domain: 'codex-workflow/plan-integrity-conflict/v1',
1618
+ domain,
1322
1619
  taskId: task.id,
1323
1620
  planHash: event.planHash,
1324
1621
  stepId: event.stepId,
@@ -1338,6 +1635,21 @@ function validatePlanIntegrityDecision(event, task, file, line) {
1338
1635
  throw new WorkflowError('STATE_CORRUPT', 'Plan-integrity changedFiles must be normalized.', { file, line });
1339
1636
  }
1340
1637
  }
1638
+ export function effectiveStepAllowedWrites(store, task, stepId) {
1639
+ const step = task.steps.find((candidate) => candidate.id === stepId);
1640
+ if (!step)
1641
+ throw new WorkflowError('NOT_FOUND', `Step not found: ${stepId}`);
1642
+ const extensions = readCorrectiveDecisionEvents(store, task)
1643
+ .filter((event) => event.stepId === stepId
1644
+ && event.planHash === task.planHash
1645
+ && event.decision === 'continue-fix'
1646
+ && event.recoveryBasis === 'plan-integrity')
1647
+ .flatMap((event) => event.planIntegrityEvidence?.kind === 'check-support-anchor-outside-allowed-writes'
1648
+ || event.planIntegrityEvidence?.kind === 'failed-check-component-owner-outside-allowed-writes'
1649
+ ? event.planIntegrityEvidence.scopeExtensions
1650
+ : []);
1651
+ return [...step.allowedWrites, ...new Set(extensions.filter((value) => !pathAllowed(value, step.allowedWrites)).sort())];
1652
+ }
1341
1653
  function validateStoredCorrectiveDecisionRecoveryReference(event, task, file, line) {
1342
1654
  if (!/^CDR-[0-9A-HJKMNP-TV-Z]{26}$/.test(event.eventId)
1343
1655
  || event.taskId !== task.id
@@ -1583,6 +1895,20 @@ function validateRemediationReviewReferences(store, task, events) {
1583
1895
  reviewDecision: reviewEvent.decision,
1584
1896
  });
1585
1897
  }
1898
+ const expectedCauses = (reviewEvent.remediationCauses ?? []).map((cause) => ({
1899
+ authority: 'external-reviewer',
1900
+ causeId: cause.causeId,
1901
+ causeFingerprint: cause.causeFingerprint,
1902
+ planConflict: cause.planConflict,
1903
+ }));
1904
+ if (canonicalJsonStringify(event.causeBindings ?? []) !== canonicalJsonStringify(expectedCauses)) {
1905
+ throw new WorkflowError('STATE_CORRUPT', 'Remediation reviewer causes do not match the exact strict review event.', {
1906
+ taskId: task.id,
1907
+ stepId: event.stepId,
1908
+ eventId: event.eventId,
1909
+ reviewEventId: reviewEvent.eventId,
1910
+ });
1911
+ }
1586
1912
  }
1587
1913
  }
1588
1914
  function validateCorrectiveDecisionCoverage(task, decisions, remediationEvents) {
@@ -1631,16 +1957,27 @@ function validateCorrectiveDecisionCoverage(task, decisions, remediationEvents)
1631
1957
  });
1632
1958
  }
1633
1959
  }
1634
- if (decision.triggeringAttemptCount === 2) {
1960
+ if (decision.recoveryBasis === 'plan-integrity') {
1635
1961
  const evidence = decision.planIntegrityEvidence;
1636
- const linked = priorEvents[0] ?? null;
1637
- if (decision.recoveryBasis !== 'plan-integrity'
1638
- || !evidence
1639
- || !linked
1640
- || linked.failureKind !== 'checks-failed'
1641
- || evidence.remediationEventId !== linked.eventId
1642
- || evidence.remediationEventHash !== linked.eventHash) {
1643
- throw new WorkflowError('STATE_CORRUPT', 'Plan-integrity recovery must bind the exact first checks-failed remediation event.', {
1962
+ const bindings = evidence?.kind === 'missing-npm-script-outside-allowed-writes'
1963
+ ? [{ eventId: evidence.remediationEventId, eventHash: evidence.remediationEventHash, attemptOrdinal: 1 }]
1964
+ : evidence?.remediationEvents ?? [];
1965
+ if (!evidence
1966
+ || bindings.length !== priorEvents.length
1967
+ || priorEvents.some((linked, index) => linked.failureKind !== 'checks-failed'
1968
+ || linked.eventId !== bindings[index]?.eventId
1969
+ || linked.eventHash !== bindings[index]?.eventHash
1970
+ || linked.attemptOrdinal !== bindings[index]?.attemptOrdinal)) {
1971
+ throw new WorkflowError('STATE_CORRUPT', 'Plan-integrity recovery must bind the exact checks-failed remediation history.', {
1972
+ taskId: task.id,
1973
+ stepId: decision.stepId,
1974
+ decisionEventId: decision.eventId,
1975
+ });
1976
+ }
1977
+ if (evidence.kind === 'failed-check-component-owner-outside-allowed-writes'
1978
+ && !priorEvents.some((linked) => linked.causeBindings?.some((cause) => cause.authority === 'mechanical'
1979
+ && cause.causeFingerprint === evidence.failedCheckCauseFingerprint))) {
1980
+ throw new WorkflowError('STATE_CORRUPT', 'Component-owner recovery must bind a machine-derived failed-check cause.', {
1644
1981
  taskId: task.id,
1645
1982
  stepId: decision.stepId,
1646
1983
  decisionEventId: decision.eventId,
@@ -1650,6 +1987,21 @@ function validateCorrectiveDecisionCoverage(task, decisions, remediationEvents)
1650
1987
  }
1651
1988
  }
1652
1989
  function validateHistoricalRemediationModes(task, stepId, remediationEvents, correctiveDecisions, recoveredEventIds, overriddenStopDecisionEventIds) {
1990
+ const causeAwareHistory = remediationEvents.length > 0
1991
+ && remediationEvents.every((event) => event.causeBindings !== undefined);
1992
+ if (causeAwareHistory) {
1993
+ for (let index = 0; index < remediationEvents.length; index += 1) {
1994
+ const prefix = remediationEvents.slice(0, index + 1);
1995
+ if (deriveRollingCauseDecision(prefix) === 'root-cause-replan' && index < remediationEvents.length - 1) {
1996
+ throw new WorkflowError('STATE_CORRUPT', 'Remediation continued after an authoritative repeated cause required replan.', {
1997
+ taskId: task.id,
1998
+ stepId,
1999
+ eventId: remediationEvents[index].eventId,
2000
+ });
2001
+ }
2002
+ }
2003
+ return;
2004
+ }
1653
2005
  for (const event of remediationEvents) {
1654
2006
  if (event.attemptOrdinal <= 2) {
1655
2007
  if (event.mode !== 'ordinary') {
@@ -1728,13 +2080,24 @@ function normalizeStringList(values, label) {
1728
2080
  return normalized;
1729
2081
  }
1730
2082
  function remediationEventMatchesFailure(event, failureKind, failureEvidence) {
1731
- return event.failureKind === failureKind
1732
- && event.failureEvidence.completionCommit === failureEvidence.completionCommit
2083
+ if (event.failureKind !== failureKind)
2084
+ return false;
2085
+ const eventExecutionRevision = event.failureEvidence.executionTaskRevision;
2086
+ const candidateExecutionRevision = failureEvidence.executionTaskRevision;
2087
+ if (eventExecutionRevision !== undefined || candidateExecutionRevision !== undefined) {
2088
+ return eventExecutionRevision !== undefined
2089
+ && candidateExecutionRevision !== undefined
2090
+ && eventExecutionRevision === candidateExecutionRevision;
2091
+ }
2092
+ return event.failureEvidence.completionCommit === failureEvidence.completionCommit
1733
2093
  && event.failureEvidence.checksHash === failureEvidence.checksHash
1734
2094
  && event.failureEvidence.reviewEventHash === failureEvidence.reviewEventHash
1735
2095
  && event.failureEvidence.reviewerAttestationHash === failureEvidence.reviewerAttestationHash;
1736
2096
  }
1737
2097
  function remediationFailureIdentityKey(failureKind, failureEvidence) {
2098
+ if (failureEvidence.executionTaskRevision !== undefined) {
2099
+ return ['execution', failureKind, String(failureEvidence.executionTaskRevision)].join('\0');
2100
+ }
1738
2101
  return [
1739
2102
  failureKind,
1740
2103
  failureEvidence.completionCommit,