codex-workflow-v2 2.0.0-alpha.7.2 → 2.0.0-alpha.7.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -36,7 +36,7 @@ cross-machine synchronization are not part of its contract.
36
36
  Node.js 22 or newer is required. Consumers pin the exact package version:
37
37
 
38
38
  ```bash
39
- npm install --save-dev --save-exact codex-workflow-v2@2.0.0-alpha.7.2
39
+ npm install --save-dev --save-exact codex-workflow-v2@2.0.0-alpha.7.2.1
40
40
  npx codex-workflow gateway handshake --repo .
41
41
  npx codex-workflow doctor --repo .
42
42
  ```
@@ -125,13 +125,23 @@ sidecars:
125
125
  - project: `adoption-posture.jsonl`
126
126
  - task: `plan-risk-audits.jsonl`, `step-review-events.jsonl`,
127
127
  `reviewer-attestations.jsonl`, `remediation-events.jsonl`,
128
- `corrective-decisions.jsonl`, `handoffs.jsonl`
128
+ `corrective-decisions.jsonl`, `corrective-decision-recoveries.jsonl`,
129
+ `remediation-mode-recoveries.jsonl`, `handoffs.jsonl`
129
130
  - milestone: `scope-change-events.jsonl`, `autonomy-contract-events.jsonl`
130
131
 
131
132
  Milestone scope-change recovery also uses Milestone-local `.transactions/` journal files.
132
133
  Broken JSONL, duplicate event ids, broken hash chains, stale revision/hash/commit bindings,
133
134
  or mismatched authority fail closed with structured diagnostics.
134
135
 
136
+ Alpha.7.2.1 can recover the one exact alpha.7.2 defect where a validated third
137
+ corrective attempt was persisted as `mode=ordinary`. `next` exposes
138
+ `task remediation-mode-recover` only for that fully verified legacy shape. Run the
139
+ returned command with its exact Task revision, actor, and writer token. Recovery
140
+ appends a bound event to `remediation-mode-recoveries.jsonl`; it never edits the
141
+ historical remediation event. An identical retry is idempotent. After recovery,
142
+ the three-attempt hard stop remains in force and `next` permits only
143
+ `split-required` or `stop-escalate`, never a fourth implementation run.
144
+
135
145
  ## Delegated approval
136
146
 
137
147
  Delegation never makes an agent a human actor. A user first approves an exact grant policy;
@@ -1,8 +1,10 @@
1
- import type { CorrectiveDecisionEvent, CorrectivePlanAuditInput, GuardedRiskCategory, RemediationEvent, RemediationFailureEvidence, RemediationFailureKind, RemediationMode, TaskState } from '../contracts.js';
1
+ import type { CorrectiveDecisionEvent, CorrectiveDecisionRecoveryEvent, CorrectivePlanAuditInput, GuardedRiskCategory, RemediationEvent, RemediationFailureEvidence, RemediationFailureKind, RemediationMode, RemediationModeRecoveryEvent, TaskState } from '../contracts.js';
2
2
  import type { FileStateStore } from '../state/store.js';
3
3
  import type { CurrentPlanRiskAudit } from './plan-risk.js';
4
4
  export declare const REMEDIATION_EVENTS_SIDECAR = "remediation-events.jsonl";
5
5
  export declare const CORRECTIVE_DECISIONS_SIDECAR = "corrective-decisions.jsonl";
6
+ export declare const CORRECTIVE_DECISION_RECOVERIES_SIDECAR = "corrective-decision-recoveries.jsonl";
7
+ export declare const REMEDIATION_MODE_RECOVERIES_SIDECAR = "remediation-mode-recoveries.jsonl";
6
8
  export interface GuardedRemediationAttemptGate {
7
9
  stepId: string;
8
10
  guardedCategory: GuardedRiskCategory;
@@ -12,11 +14,26 @@ export interface GuardedRemediationAttemptGate {
12
14
  correctiveDecision: CorrectiveDecisionEvent | null;
13
15
  posture: 'ordinary' | 'decision-required' | 'continue-fix' | 'replan-required' | 'replan-ready' | 'split-required' | 'stop-escalate' | 'hard-stop';
14
16
  }
17
+ export interface RemediationModeRecoveryAssessment {
18
+ task: TaskState;
19
+ remediation: RemediationEvent;
20
+ decision: CorrectiveDecisionEvent;
21
+ correctiveRecovery: CorrectiveDecisionRecoveryEvent;
22
+ existingRecovery: RemediationModeRecoveryEvent | null;
23
+ actor: string;
24
+ c1ClaimEventHash: string | null;
25
+ writerLeaseTokenHash: string | null;
26
+ evidenceFingerprint: string;
27
+ }
15
28
  export declare function readRemediationEvents(store: FileStateStore, task: TaskState): RemediationEvent[];
16
29
  export declare function readCorrectiveDecisionEvents(store: FileStateStore, task: TaskState): CorrectiveDecisionEvent[];
17
30
  export declare function defaultCorrectiveAuditorActor(taskId: string): string;
18
31
  export declare function assertCorrectiveAuditorIndependence(store: FileStateStore, task: TaskState, stepId: string, auditor: string): void;
32
+ export declare function readRemediationModeRecoveryEvents(store: FileStateStore, task: TaskState): RemediationModeRecoveryEvent[];
19
33
  export declare function appendRemediationEvent(store: FileStateStore, task: TaskState, stepId: string, failureKind: RemediationFailureKind, failureEvidence: RemediationFailureEvidence, planRiskAudit: CurrentPlanRiskAudit, now: Date): RemediationEvent;
34
+ export declare function findRemediationModeRecoveryCandidate(store: FileStateStore, task: TaskState): RemediationModeRecoveryAssessment | null;
35
+ export declare function assessRemediationModeRecovery(store: FileStateStore, task: TaskState, stepId: string, actor: string, writerToken: string | null): RemediationModeRecoveryAssessment;
36
+ export declare function appendRemediationModeRecovery(store: FileStateStore, task: TaskState, stepId: string, expectedRevision: number, actor: string, writerToken: string | null, now: Date): RemediationModeRecoveryEvent;
20
37
  export declare function appendCorrectiveDecisionEvent(store: FileStateStore, task: TaskState, stepId: string, correctiveAudit: CorrectivePlanAuditInput, planRiskAudit: CurrentPlanRiskAudit, planHash: string, now: Date): CorrectiveDecisionEvent;
21
38
  export declare function assertGuardedRemediationAttemptAllowed(store: FileStateStore, task: TaskState, stepId: string, planRiskAudit: CurrentPlanRiskAudit, recoveredDecisionEventId?: string | null): GuardedRemediationAttemptGate;
22
39
  export declare function readGuardedRemediationPostureForStep(store: FileStateStore, task: TaskState, stepId: string, planRiskAudit: CurrentPlanRiskAudit): GuardedRemediationAttemptGate | null;
@@ -1,11 +1,15 @@
1
- import { existsSync } from 'node:fs';
1
+ import { closeSync, existsSync, openSync, readFileSync, unlinkSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { WorkflowError } from '../errors.js';
4
4
  import { createUlid } from '../ulid.js';
5
- import { assertHash64 } from './store-sidecars.js';
5
+ import { assertHash64, canonicalJsonStringify, sha256Hex } from './store-sidecars.js';
6
+ import { readTaskC1Posture } from './handoff.js';
6
7
  import { buildCurrentStrictStepReviewCycle, readReviewerAttestations, readStepReviewEvents, } from './review.js';
7
8
  export const REMEDIATION_EVENTS_SIDECAR = 'remediation-events.jsonl';
8
9
  export const CORRECTIVE_DECISIONS_SIDECAR = 'corrective-decisions.jsonl';
10
+ export const CORRECTIVE_DECISION_RECOVERIES_SIDECAR = 'corrective-decision-recoveries.jsonl';
11
+ export const REMEDIATION_MODE_RECOVERIES_SIDECAR = 'remediation-mode-recoveries.jsonl';
12
+ const REMEDIATION_MODE_RECOVERY_LOCK = '.remediation-mode-recovery.lock';
9
13
  const COMMIT_SHA_PATTERN = /^[a-f0-9]{40}$/;
10
14
  const REMEDIATION_FAILURE_KINDS = new Set([
11
15
  'checks-failed',
@@ -65,15 +69,35 @@ export function assertCorrectiveAuditorIndependence(store, task, stepId, auditor
65
69
  function validateRemediationHistory(store, task) {
66
70
  const remediationEvents = readRawRemediationEvents(store, task);
67
71
  const correctiveDecisionEvents = readRawCorrectiveDecisionEvents(store, task);
72
+ const modeRecoveries = readRemediationModeRecoveryEvents(store, task);
68
73
  validateRemediationAttemptOrdinals(remediationEvents, task);
69
74
  validateRemediationReviewReferences(store, task, remediationEvents);
70
75
  validateCorrectiveDecisionCoverage(task, correctiveDecisionEvents, remediationEvents);
71
- validateHistoricalRemediationModesForAllSteps(task, remediationEvents, correctiveDecisionEvents);
76
+ const recoveredEventIds = validateRemediationModeRecoveries(store, task, remediationEvents, correctiveDecisionEvents, modeRecoveries);
77
+ validateHistoricalRemediationModesForAllSteps(task, remediationEvents, correctiveDecisionEvents, recoveredEventIds);
72
78
  return {
73
79
  remediationEvents,
74
80
  correctiveDecisionEvents,
75
81
  };
76
82
  }
83
+ export function readRemediationModeRecoveryEvents(store, task) {
84
+ const root = store.taskRoot(task.projectId, task.id);
85
+ const file = path.join(root, REMEDIATION_MODE_RECOVERIES_SIDECAR);
86
+ if (!existsSync(file))
87
+ return [];
88
+ return store.readSidecarEvents(root, REMEDIATION_MODE_RECOVERIES_SIDECAR, {
89
+ validateEvent: (event, context) => validateStoredRemediationModeRecovery(event, task, context.file, context.line),
90
+ });
91
+ }
92
+ function readRawCorrectiveDecisionRecoveries(store, task) {
93
+ const root = store.taskRoot(task.projectId, task.id);
94
+ const file = path.join(root, CORRECTIVE_DECISION_RECOVERIES_SIDECAR);
95
+ if (!existsSync(file))
96
+ return [];
97
+ return store.readSidecarEvents(root, CORRECTIVE_DECISION_RECOVERIES_SIDECAR, {
98
+ validateEvent: (event, context) => validateStoredCorrectiveDecisionRecoveryReference(event, task, context.file, context.line),
99
+ });
100
+ }
77
101
  function readRawRemediationEvents(store, task) {
78
102
  const sidecarFile = path.join(store.taskRoot(task.projectId, task.id), REMEDIATION_EVENTS_SIDECAR);
79
103
  if (!existsSync(sidecarFile))
@@ -91,6 +115,7 @@ function readRawCorrectiveDecisionEvents(store, task) {
91
115
  });
92
116
  }
93
117
  export function appendRemediationEvent(store, task, stepId, failureKind, failureEvidence, planRiskAudit, now) {
118
+ const recoveredDecisionEventId = findCorrectiveRecoveryDecisionIdForFailure(store, task, stepId);
94
119
  const existing = readRemediationEvents(store, task).filter((event) => event.stepId === stepId);
95
120
  const matching = existing.filter((event) => remediationEventMatchesFailure(event, failureKind, failureEvidence));
96
121
  if (matching.length > 1) {
@@ -104,7 +129,9 @@ export function appendRemediationEvent(store, task, stepId, failureKind, failure
104
129
  if (matching.length === 1) {
105
130
  const reused = matching[0];
106
131
  const priorEvents = existing.filter((event) => event.attemptOrdinal < reused.attemptOrdinal);
107
- const gate = deriveAttemptGateFromHistory(task, stepId, planRiskAudit, priorEvents, readCorrectiveDecisionEvents(store, task));
132
+ const gate = deriveAttemptGateFromHistory(task, stepId, planRiskAudit, priorEvents, readCorrectiveDecisionEvents(store, task), {
133
+ recoveredDecisionEventId,
134
+ });
108
135
  if (reused.attemptOrdinal !== gate.nextAttemptOrdinal || reused.mode !== gate.mode || reused.guardedCategory !== gate.guardedCategory) {
109
136
  throw new WorkflowError('STATE_CORRUPT', 'Existing remediation event conflicts with the current guarded retry binding.', {
110
137
  taskId: task.id,
@@ -118,7 +145,9 @@ export function appendRemediationEvent(store, task, stepId, failureKind, failure
118
145
  }
119
146
  return reused;
120
147
  }
121
- const gate = deriveAttemptGateFromHistory(task, stepId, planRiskAudit, existing, readCorrectiveDecisionEvents(store, task));
148
+ const gate = deriveAttemptGateFromHistory(task, stepId, planRiskAudit, existing, readCorrectiveDecisionEvents(store, task), {
149
+ recoveredDecisionEventId,
150
+ });
122
151
  return store.appendSidecarEvent(store.taskRoot(task.projectId, task.id), REMEDIATION_EVENTS_SIDECAR, {
123
152
  eventId: `RME-${createUlid(now.getTime())}`,
124
153
  recordedAt: now.toISOString(),
@@ -133,6 +162,290 @@ export function appendRemediationEvent(store, task, stepId, failureKind, failure
133
162
  validateEvent: (event, context) => validateStoredRemediationEvent(event, task, context.file, context.line),
134
163
  });
135
164
  }
165
+ function findCorrectiveRecoveryDecisionIdForFailure(store, task, stepId) {
166
+ const remediations = readRawRemediationEvents(store, task).filter((event) => event.stepId === stepId);
167
+ if (remediations.length !== 2)
168
+ return null;
169
+ const decisions = readRawCorrectiveDecisionEvents(store, task).filter((event) => event.stepId === stepId
170
+ && event.triggeringAttemptCount === 3
171
+ && event.decision === 'continue-fix'
172
+ && event.planHash !== task.planHash);
173
+ if (decisions.length !== 1)
174
+ return null;
175
+ const recoveries = readRawCorrectiveDecisionRecoveries(store, task).filter((event) => event.stepId === stepId
176
+ && event.triggeringAttemptCount === 3
177
+ && event.correctiveDecisionEventId === decisions[0].eventId
178
+ && event.correctiveDecisionEventHash === decisions[0].eventHash
179
+ && event.reboundPlanHash === task.planHash);
180
+ if (recoveries.length > 1) {
181
+ throw new WorkflowError('STATE_CORRUPT', 'Multiple corrective recoveries match one remediation attempt.', {
182
+ taskId: task.id,
183
+ stepId,
184
+ recoveryEventIds: recoveries.map((event) => event.eventId),
185
+ });
186
+ }
187
+ return recoveries[0]?.correctiveDecisionEventId ?? null;
188
+ }
189
+ export function findRemediationModeRecoveryCandidate(store, task) {
190
+ const raw = readRawRemediationEvents(store, task);
191
+ const candidates = raw.filter((event) => event.attemptOrdinal > 2 && event.mode === 'ordinary');
192
+ if (candidates.length === 0)
193
+ return null;
194
+ if (candidates.length !== 1) {
195
+ throw new WorkflowError('STATE_CORRUPT', 'Multiple malformed remediation modes are not recoverable.', {
196
+ taskId: task.id,
197
+ eventIds: candidates.map((event) => event.eventId),
198
+ });
199
+ }
200
+ const posture = readTaskC1Posture(store, task);
201
+ const lease = readWriterLeaseForModeRecovery(store, task);
202
+ const actor = posture.state === 'claimed' ? posture.claimant : lease?.owner ?? 'worker';
203
+ return assessRemediationModeRecovery(store, task, candidates[0].stepId, actor, lease?.token ?? null);
204
+ }
205
+ export function assessRemediationModeRecovery(store, task, stepId, actor, writerToken) {
206
+ const normalizedActor = actor.trim();
207
+ if (!normalizedActor)
208
+ throw new WorkflowError('INVALID_ARGUMENT', 'Remediation mode recovery actor is required.');
209
+ const step = task.steps.find((candidate) => candidate.id === stepId) ?? null;
210
+ if (!step)
211
+ throw new WorkflowError('NOT_FOUND', `Step not found: ${stepId}`);
212
+ if (task.status !== 'needs_fix' || step.status !== 'failed') {
213
+ throw new WorkflowError('TRANSITION_BLOCKED', 'Remediation mode recovery requires the exact failed Task/Step posture.', {
214
+ taskId: task.id,
215
+ taskStatus: task.status,
216
+ stepId,
217
+ stepStatus: step.status,
218
+ });
219
+ }
220
+ if (!task.planHash)
221
+ throw new WorkflowError('STATE_CORRUPT', `Task ${task.id} is missing its Plan hash.`);
222
+ const remediations = readRawRemediationEvents(store, task);
223
+ const decisions = readRawCorrectiveDecisionEvents(store, task);
224
+ validateRemediationAttemptOrdinals(remediations, task);
225
+ validateRemediationReviewReferences(store, task, remediations);
226
+ validateCorrectiveDecisionCoverage(task, decisions, remediations);
227
+ const malformed = remediations.filter((event) => event.attemptOrdinal > 2 && event.mode === 'ordinary');
228
+ if (malformed.length !== 1 || malformed[0].stepId !== stepId || malformed[0].attemptOrdinal !== 3) {
229
+ throw new WorkflowError('STATE_CORRUPT', 'State does not contain the one exact alpha.7.2 remediation mode omission.', {
230
+ taskId: task.id,
231
+ stepId,
232
+ malformedEventIds: malformed.map((event) => event.eventId),
233
+ });
234
+ }
235
+ const stepRemediations = remediations.filter((event) => event.stepId === stepId);
236
+ if (stepRemediations.length !== 3
237
+ || stepRemediations[0].mode !== 'ordinary'
238
+ || stepRemediations[1].mode !== 'ordinary'
239
+ || stepRemediations[2].eventId !== malformed[0].eventId) {
240
+ throw new WorkflowError('STATE_CORRUPT', 'Remediation mode recovery requires exactly two ordinary attempts and one malformed third attempt.', {
241
+ taskId: task.id,
242
+ stepId,
243
+ });
244
+ }
245
+ const decisionMatches = decisions.filter((event) => event.stepId === stepId
246
+ && event.triggeringAttemptCount === 3
247
+ && event.decision === 'continue-fix'
248
+ && sameStringArray(event.coveredEventIds, stepRemediations.slice(0, 2).map((event) => event.eventId)));
249
+ if (decisionMatches.length !== 1) {
250
+ throw new WorkflowError('STATE_CORRUPT', 'Remediation mode recovery requires one exact continue-fix decision.', {
251
+ taskId: task.id,
252
+ stepId,
253
+ decisionEventIds: decisionMatches.map((event) => event.eventId),
254
+ });
255
+ }
256
+ const decision = decisionMatches[0];
257
+ const correctiveRecoveries = readRawCorrectiveDecisionRecoveries(store, task).filter((event) => event.stepId === stepId
258
+ && event.triggeringAttemptCount === 3
259
+ && event.correctiveDecisionEventId === decision.eventId
260
+ && event.correctiveDecisionEventHash === decision.eventHash
261
+ && event.reboundPlanHash === task.planHash);
262
+ if (correctiveRecoveries.length !== 1) {
263
+ throw new WorkflowError('STATE_CORRUPT', 'Remediation mode recovery requires one exact corrective-decision recovery.', {
264
+ taskId: task.id,
265
+ stepId,
266
+ recoveryEventIds: correctiveRecoveries.map((event) => event.eventId),
267
+ });
268
+ }
269
+ const correctiveRecovery = correctiveRecoveries[0];
270
+ const authority = requireRemediationModeRecoveryAuthority(store, task, normalizedActor, writerToken);
271
+ const existing = readRemediationModeRecoveryEvents(store, task).filter((event) => event.remediationEventId === malformed[0].eventId);
272
+ if (existing.length > 1) {
273
+ throw new WorkflowError('STATE_CORRUPT', 'Multiple remediation mode recoveries target one event.', {
274
+ taskId: task.id,
275
+ remediationEventId: malformed[0].eventId,
276
+ });
277
+ }
278
+ if (existing[0]) {
279
+ validateRemediationModeRecoveryBinding(existing[0], malformed[0], decision, correctiveRecovery);
280
+ const retryMismatches = [
281
+ existing[0].taskRevision !== task.revision ? 'taskRevision' : null,
282
+ existing[0].planHash !== task.planHash ? 'planHash' : null,
283
+ existing[0].actor !== normalizedActor ? 'actor' : null,
284
+ existing[0].c1ClaimEventHash !== authority.c1ClaimEventHash ? 'c1ClaimEventHash' : null,
285
+ existing[0].writerLeaseTokenHash !== authority.writerLeaseTokenHash ? 'writerLeaseTokenHash' : null,
286
+ ].filter((value) => value !== null);
287
+ if (retryMismatches.length > 0) {
288
+ throw new WorkflowError('STATE_CONFLICT', 'Existing remediation mode recovery conflicts with the requested retry binding.', {
289
+ taskId: task.id,
290
+ stepId,
291
+ recoveryEventId: existing[0].eventId,
292
+ mismatches: retryMismatches,
293
+ });
294
+ }
295
+ }
296
+ const evidenceFingerprint = sha256Hex(canonicalJsonStringify({
297
+ taskId: task.id,
298
+ taskRevision: task.revision,
299
+ stepId,
300
+ planHash: task.planHash,
301
+ remediationEventHash: malformed[0].eventHash,
302
+ correctiveDecisionEventHash: decision.eventHash,
303
+ correctiveDecisionRecoveryEventHash: correctiveRecovery.eventHash,
304
+ actor: normalizedActor,
305
+ c1ClaimEventHash: authority.c1ClaimEventHash,
306
+ writerLeaseTokenHash: authority.writerLeaseTokenHash,
307
+ }));
308
+ return {
309
+ task,
310
+ remediation: malformed[0],
311
+ decision,
312
+ correctiveRecovery,
313
+ existingRecovery: existing[0] ?? null,
314
+ actor: normalizedActor,
315
+ c1ClaimEventHash: authority.c1ClaimEventHash,
316
+ writerLeaseTokenHash: authority.writerLeaseTokenHash,
317
+ evidenceFingerprint,
318
+ };
319
+ }
320
+ export function appendRemediationModeRecovery(store, task, stepId, expectedRevision, actor, writerToken, now) {
321
+ if (task.revision !== expectedRevision) {
322
+ throw new WorkflowError('STATE_CONFLICT', `State revision conflict for ${task.id}`, {
323
+ expectedRevision,
324
+ actualRevision: task.revision,
325
+ });
326
+ }
327
+ const preflight = assessRemediationModeRecovery(store, task, stepId, actor, writerToken);
328
+ const root = store.taskRoot(task.projectId, task.id);
329
+ const lockFile = path.join(root, REMEDIATION_MODE_RECOVERY_LOCK);
330
+ let descriptor;
331
+ try {
332
+ descriptor = openSync(lockFile, 'wx', 0o600);
333
+ closeSync(descriptor);
334
+ }
335
+ catch (error) {
336
+ if (existsSync(lockFile)) {
337
+ throw new WorkflowError('LOCKED', `Remediation mode recovery for ${task.id} is already being serialized.`, {
338
+ taskId: task.id,
339
+ stepId,
340
+ });
341
+ }
342
+ throw error;
343
+ }
344
+ try {
345
+ const currentTask = store.readTask(task.projectId, task.id);
346
+ if (currentTask.revision !== expectedRevision) {
347
+ throw new WorkflowError('STATE_CONFLICT', `State revision conflict for ${task.id}`, {
348
+ expectedRevision,
349
+ actualRevision: currentTask.revision,
350
+ });
351
+ }
352
+ let underLock;
353
+ try {
354
+ underLock = assessRemediationModeRecovery(store, currentTask, stepId, actor, writerToken);
355
+ }
356
+ catch (error) {
357
+ throw new WorkflowError('STATE_CONFLICT', 'Remediation mode recovery evidence changed after preflight.', {
358
+ taskId: task.id,
359
+ stepId,
360
+ cause: error instanceof Error ? error.message : String(error),
361
+ });
362
+ }
363
+ if (underLock.existingRecovery)
364
+ return underLock.existingRecovery;
365
+ if (underLock.evidenceFingerprint !== preflight.evidenceFingerprint) {
366
+ throw new WorkflowError('STATE_CONFLICT', 'Remediation mode recovery evidence changed after preflight.', {
367
+ taskId: task.id,
368
+ stepId,
369
+ });
370
+ }
371
+ return store.appendSidecarEvent(root, REMEDIATION_MODE_RECOVERIES_SIDECAR, {
372
+ eventId: `RMR-${createUlid(now.getTime())}`,
373
+ recordedAt: now.toISOString(),
374
+ taskId: task.id,
375
+ stepId,
376
+ taskRevision: expectedRevision,
377
+ remediationEventId: underLock.remediation.eventId,
378
+ remediationEventHash: underLock.remediation.eventHash,
379
+ correctiveDecisionEventId: underLock.decision.eventId,
380
+ correctiveDecisionEventHash: underLock.decision.eventHash,
381
+ correctiveDecisionRecoveryEventId: underLock.correctiveRecovery.eventId,
382
+ correctiveDecisionRecoveryEventHash: underLock.correctiveRecovery.eventHash,
383
+ attemptOrdinal: 3,
384
+ planHash: currentTask.planHash,
385
+ actor: underLock.actor,
386
+ c1ClaimEventHash: underLock.c1ClaimEventHash,
387
+ writerLeaseTokenHash: underLock.writerLeaseTokenHash,
388
+ fromMode: 'ordinary',
389
+ toMode: 'corrective',
390
+ reason: 'alpha7.2-recovered-continue-fix-mode-omission',
391
+ }, {
392
+ validateEvent: (event, context) => validateStoredRemediationModeRecovery(event, currentTask, context.file, context.line),
393
+ });
394
+ }
395
+ finally {
396
+ if (existsSync(lockFile))
397
+ unlinkSync(lockFile);
398
+ }
399
+ }
400
+ function requireRemediationModeRecoveryAuthority(store, task, actor, writerToken) {
401
+ const posture = readTaskC1Posture(store, task);
402
+ const lease = readWriterLeaseForModeRecovery(store, task);
403
+ if (posture.state === 'pending') {
404
+ throw new WorkflowError('TRANSITION_BLOCKED', 'Pending C1 handoff must be claimed before remediation mode recovery.');
405
+ }
406
+ if (posture.state === 'claimed') {
407
+ const tokenHash = writerToken ? sha256Hex(writerToken) : null;
408
+ if (!lease
409
+ || actor !== posture.claimant
410
+ || lease.owner !== posture.claimant
411
+ || !writerToken
412
+ || lease.token !== writerToken
413
+ || tokenHash !== posture.writerLeaseTokenHash) {
414
+ throw new WorkflowError('TRANSITION_BLOCKED', 'Remediation mode recovery requires the claimed C1 actor and exact writer token.', {
415
+ taskId: task.id,
416
+ claimant: posture.claimant,
417
+ leaseOwner: lease?.owner ?? null,
418
+ });
419
+ }
420
+ return { c1ClaimEventHash: posture.latestEvent.eventHash, writerLeaseTokenHash: tokenHash };
421
+ }
422
+ if (lease && (lease.owner !== actor || !writerToken || lease.token !== writerToken)) {
423
+ throw new WorkflowError('TRANSITION_BLOCKED', 'Existing writer lease is not owned by the remediation recovery actor/token.');
424
+ }
425
+ if (!lease && writerToken) {
426
+ throw new WorkflowError('TRANSITION_BLOCKED', 'A writer token was provided without an existing writer lease.');
427
+ }
428
+ return {
429
+ c1ClaimEventHash: posture.latestEvent?.eventHash ?? null,
430
+ writerLeaseTokenHash: lease ? sha256Hex(lease.token) : null,
431
+ };
432
+ }
433
+ function readWriterLeaseForModeRecovery(store, task) {
434
+ const file = path.join(store.lockRoot(task.projectId), `${task.id}.json`);
435
+ if (!existsSync(file))
436
+ return null;
437
+ let lease;
438
+ try {
439
+ lease = JSON.parse(readFileSync(file, 'utf8'));
440
+ }
441
+ catch {
442
+ throw new WorkflowError('STATE_CORRUPT', `Writer lease is unreadable: ${file}`);
443
+ }
444
+ if (lease.entityId !== task.id || typeof lease.owner !== 'string' || typeof lease.token !== 'string') {
445
+ throw new WorkflowError('STATE_CORRUPT', `Writer lease does not match Task ${task.id}.`);
446
+ }
447
+ return lease;
448
+ }
136
449
  export function appendCorrectiveDecisionEvent(store, task, stepId, correctiveAudit, planRiskAudit, planHash, now) {
137
450
  validateCorrectiveDecisionAudit(correctiveAudit);
138
451
  const remediationEvents = readRemediationEvents(store, task).filter((event) => event.stepId === stepId);
@@ -553,6 +866,142 @@ function validateStoredCorrectiveDecisionEvent(event, task, file, line) {
553
866
  normalizeStringList(event.coveredEventIds, 'Corrective decision coveredEventIds');
554
867
  assertHash64(event.planHash, `Corrective decision planHash at ${file}:${line}`);
555
868
  }
869
+ function validateStoredCorrectiveDecisionRecoveryReference(event, task, file, line) {
870
+ if (!/^CDR-[0-9A-HJKMNP-TV-Z]{26}$/.test(event.eventId)
871
+ || event.taskId !== task.id
872
+ || event.triggeringAttemptCount !== 3) {
873
+ throw new WorkflowError('STATE_CORRUPT', 'Corrective recovery reference does not match the Task/ordinal.', {
874
+ file,
875
+ line,
876
+ taskId: task.id,
877
+ eventTaskId: event.taskId,
878
+ triggeringAttemptCount: event.triggeringAttemptCount,
879
+ });
880
+ }
881
+ if (typeof event.stepId !== 'string' || !event.stepId.trim() || typeof event.actor !== 'string' || !event.actor.trim()) {
882
+ throw new WorkflowError('STATE_CORRUPT', 'Corrective recovery reference is missing Step or actor.', { file, line });
883
+ }
884
+ for (const hash of [
885
+ event.correctiveDecisionEventHash,
886
+ event.decisionPlanHash,
887
+ event.reboundPlanHash,
888
+ event.semanticPlanHash,
889
+ event.fromKnowledgeMapHash,
890
+ event.toKnowledgeMapHash,
891
+ event.sourcePlanRiskAuditEventHash,
892
+ event.reboundPlanRiskAuditEventHash,
893
+ event.knowledgeMapApprovalFingerprint,
894
+ event.taskAuthorizationFingerprint,
895
+ event.delegationGrantFingerprint,
896
+ event.c1EvidenceFingerprint,
897
+ event.evidenceFingerprint,
898
+ ]) {
899
+ assertHash64(hash, `Corrective recovery hash at ${file}:${line}`);
900
+ }
901
+ if (!Number.isInteger(event.fromKnowledgeMapRevision)
902
+ || event.fromKnowledgeMapRevision < 1
903
+ || !Number.isInteger(event.toKnowledgeMapRevision)
904
+ || event.toKnowledgeMapRevision < 1) {
905
+ throw new WorkflowError('STATE_CORRUPT', 'Corrective recovery Knowledge Map revisions are malformed.', { file, line });
906
+ }
907
+ }
908
+ function validateStoredRemediationModeRecovery(event, task, file, line) {
909
+ if (!/^RMR-[0-9A-HJKMNP-TV-Z]{26}$/.test(event.eventId)) {
910
+ throw new WorkflowError('STATE_CORRUPT', 'Remediation mode recovery eventId is malformed.', { file, line });
911
+ }
912
+ if (event.taskId !== task.id || typeof event.stepId !== 'string' || !event.stepId.trim()) {
913
+ throw new WorkflowError('STATE_CORRUPT', 'Remediation mode recovery does not match the current Task.', {
914
+ file,
915
+ line,
916
+ taskId: task.id,
917
+ eventTaskId: event.taskId,
918
+ });
919
+ }
920
+ if (!Number.isInteger(event.taskRevision) || event.taskRevision < 1 || event.attemptOrdinal !== 3) {
921
+ throw new WorkflowError('STATE_CORRUPT', 'Remediation mode recovery has an invalid revision or ordinal.', { file, line });
922
+ }
923
+ if (event.fromMode !== 'ordinary'
924
+ || event.toMode !== 'corrective'
925
+ || event.reason !== 'alpha7.2-recovered-continue-fix-mode-omission') {
926
+ throw new WorkflowError('STATE_CORRUPT', 'Remediation mode recovery has an unsupported correction.', { file, line });
927
+ }
928
+ if (!/^RME-[0-9A-HJKMNP-TV-Z]{26}$/.test(event.remediationEventId)
929
+ || !/^CRD-[0-9A-HJKMNP-TV-Z]{26}$/.test(event.correctiveDecisionEventId)
930
+ || !/^CDR-[0-9A-HJKMNP-TV-Z]{26}$/.test(event.correctiveDecisionRecoveryEventId)
931
+ || typeof event.actor !== 'string'
932
+ || !event.actor.trim()) {
933
+ throw new WorkflowError('STATE_CORRUPT', 'Remediation mode recovery identifiers are malformed.', { file, line });
934
+ }
935
+ for (const [label, value] of [
936
+ ['remediationEventHash', event.remediationEventHash],
937
+ ['correctiveDecisionEventHash', event.correctiveDecisionEventHash],
938
+ ['correctiveDecisionRecoveryEventHash', event.correctiveDecisionRecoveryEventHash],
939
+ ['planHash', event.planHash],
940
+ ]) {
941
+ assertHash64(value, `Remediation mode recovery ${label} at ${file}:${line}`);
942
+ }
943
+ if (event.c1ClaimEventHash !== null)
944
+ assertHash64(event.c1ClaimEventHash, `Remediation mode recovery C1 hash at ${file}:${line}`);
945
+ if (event.writerLeaseTokenHash !== null)
946
+ assertHash64(event.writerLeaseTokenHash, `Remediation mode recovery token hash at ${file}:${line}`);
947
+ }
948
+ function validateRemediationModeRecoveryBinding(recovery, remediation, decision, correctiveRecovery) {
949
+ const mismatches = [
950
+ recovery.taskId !== remediation.taskId ? 'taskId' : null,
951
+ recovery.stepId !== remediation.stepId ? 'stepId' : null,
952
+ recovery.remediationEventId !== remediation.eventId ? 'remediationEventId' : null,
953
+ recovery.remediationEventHash !== remediation.eventHash ? 'remediationEventHash' : null,
954
+ recovery.correctiveDecisionEventId !== decision.eventId ? 'correctiveDecisionEventId' : null,
955
+ recovery.correctiveDecisionEventHash !== decision.eventHash ? 'correctiveDecisionEventHash' : null,
956
+ recovery.correctiveDecisionRecoveryEventId !== correctiveRecovery.eventId ? 'correctiveDecisionRecoveryEventId' : null,
957
+ recovery.correctiveDecisionRecoveryEventHash !== correctiveRecovery.eventHash ? 'correctiveDecisionRecoveryEventHash' : null,
958
+ recovery.attemptOrdinal !== remediation.attemptOrdinal ? 'attemptOrdinal' : null,
959
+ recovery.planHash !== correctiveRecovery.reboundPlanHash ? 'planHash' : null,
960
+ recovery.actor !== correctiveRecovery.actor ? 'actor' : null,
961
+ ].filter((value) => value !== null);
962
+ if (mismatches.length > 0) {
963
+ throw new WorkflowError('STATE_CORRUPT', 'Remediation mode recovery does not match its append-only evidence.', {
964
+ recoveryEventId: recovery.eventId,
965
+ mismatches,
966
+ });
967
+ }
968
+ }
969
+ function validateRemediationModeRecoveries(store, task, remediations, decisions, recoveries) {
970
+ const correctiveRecoveries = readRawCorrectiveDecisionRecoveries(store, task);
971
+ const recoveredEventIds = new Set();
972
+ for (const recovery of recoveries) {
973
+ if (recoveredEventIds.has(recovery.remediationEventId)) {
974
+ throw new WorkflowError('STATE_CORRUPT', 'Duplicate remediation mode recoveries target one event.', {
975
+ taskId: task.id,
976
+ remediationEventId: recovery.remediationEventId,
977
+ });
978
+ }
979
+ const remediation = remediations.find((event) => event.eventId === recovery.remediationEventId) ?? null;
980
+ const decision = decisions.find((event) => event.eventId === recovery.correctiveDecisionEventId) ?? null;
981
+ const correctiveRecovery = correctiveRecoveries.find((event) => event.eventId === recovery.correctiveDecisionRecoveryEventId) ?? null;
982
+ if (!remediation || !decision || !correctiveRecovery) {
983
+ throw new WorkflowError('STATE_CORRUPT', 'Remediation mode recovery references missing evidence.', {
984
+ taskId: task.id,
985
+ recoveryEventId: recovery.eventId,
986
+ });
987
+ }
988
+ if (recovery.taskRevision !== task.revision
989
+ || recovery.planHash !== task.planHash
990
+ || remediation.mode !== 'ordinary'
991
+ || remediation.attemptOrdinal !== 3
992
+ || decision.decision !== 'continue-fix'
993
+ || decision.triggeringAttemptCount !== 3
994
+ || correctiveRecovery.correctiveDecisionEventId !== decision.eventId) {
995
+ throw new WorkflowError('STATE_CORRUPT', 'Remediation mode recovery references an ineligible lifecycle shape.', {
996
+ taskId: task.id,
997
+ recoveryEventId: recovery.eventId,
998
+ });
999
+ }
1000
+ validateRemediationModeRecoveryBinding(recovery, remediation, decision, correctiveRecovery);
1001
+ recoveredEventIds.add(remediation.eventId);
1002
+ }
1003
+ return recoveredEventIds;
1004
+ }
556
1005
  function validateRemediationAttemptOrdinals(events, task) {
557
1006
  const attemptsByStep = new Map();
558
1007
  for (const event of events) {
@@ -712,7 +1161,7 @@ function validateCorrectiveDecisionCoverage(task, decisions, remediationEvents)
712
1161
  }
713
1162
  }
714
1163
  }
715
- function validateHistoricalRemediationModes(task, stepId, remediationEvents, correctiveDecisions) {
1164
+ function validateHistoricalRemediationModes(task, stepId, remediationEvents, correctiveDecisions, recoveredEventIds) {
716
1165
  for (const event of remediationEvents) {
717
1166
  if (event.attemptOrdinal <= 2) {
718
1167
  if (event.mode !== 'ordinary') {
@@ -726,7 +1175,7 @@ function validateHistoricalRemediationModes(task, stepId, remediationEvents, cor
726
1175
  }
727
1176
  continue;
728
1177
  }
729
- if (event.mode === 'ordinary') {
1178
+ if (event.mode === 'ordinary' && !recoveredEventIds.has(event.eventId)) {
730
1179
  throw new WorkflowError('STATE_CORRUPT', 'Guarded remediation attempt ordinals above 2 must not remain ordinary.', {
731
1180
  taskId: task.id,
732
1181
  stepId,
@@ -753,13 +1202,13 @@ function validateHistoricalRemediationModes(task, stepId, remediationEvents, cor
753
1202
  }
754
1203
  }
755
1204
  }
756
- function validateHistoricalRemediationModesForAllSteps(task, remediationEvents, correctiveDecisions) {
1205
+ function validateHistoricalRemediationModesForAllSteps(task, remediationEvents, correctiveDecisions, recoveredEventIds) {
757
1206
  const stepIds = new Set(remediationEvents.map((event) => event.stepId));
758
1207
  for (const stepId of stepIds) {
759
1208
  const stepEvents = remediationEvents
760
1209
  .filter((event) => event.stepId === stepId)
761
1210
  .sort((left, right) => left.attemptOrdinal - right.attemptOrdinal);
762
- validateHistoricalRemediationModes(task, stepId, stepEvents, correctiveDecisions);
1211
+ validateHistoricalRemediationModes(task, stepId, stepEvents, correctiveDecisions, recoveredEventIds);
763
1212
  }
764
1213
  }
765
1214
  function validateCorrectiveDecisionAudit(audit) {