codex-workflow-v2 2.0.0-alpha.7.1 → 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.
@@ -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);
@@ -210,10 +523,11 @@ export function appendCorrectiveDecisionEvent(store, task, stepId, correctiveAud
210
523
  validateEvent: (event, context) => validateStoredCorrectiveDecisionEvent(event, task, context.file, context.line),
211
524
  });
212
525
  }
213
- export function assertGuardedRemediationAttemptAllowed(store, task, stepId, planRiskAudit) {
526
+ export function assertGuardedRemediationAttemptAllowed(store, task, stepId, planRiskAudit, recoveredDecisionEventId = null) {
214
527
  const remediationEvents = readRemediationEvents(store, task).filter((event) => event.stepId === stepId);
215
528
  return deriveAttemptGateFromHistory(task, stepId, planRiskAudit, remediationEvents, readCorrectiveDecisionEvents(store, task), {
216
529
  enforceRuntimeGate: true,
530
+ recoveredDecisionEventId,
217
531
  });
218
532
  }
219
533
  export function readGuardedRemediationPostureForStep(store, task, stepId, planRiskAudit) {
@@ -229,7 +543,7 @@ export function readGuardedRemediationPostureForStep(store, task, stepId, planRi
229
543
  const remediationEvents = readRemediationEvents(store, task).filter((event) => event.stepId === stepId);
230
544
  if (remediationEvents.length < 2)
231
545
  return null;
232
- if (step.status === 'failed') {
546
+ if (step.status === 'failed' || step.status === 'planned') {
233
547
  return deriveAttemptGateFromHistory(task, stepId, planRiskAudit, remediationEvents, readCorrectiveDecisionEvents(store, task));
234
548
  }
235
549
  if (task.status !== 'blocked' || step.status !== 'in_progress' || !step.evidence)
@@ -330,6 +644,18 @@ function deriveAttemptGateFromHistory(task, stepId, planRiskAudit, remediationEv
330
644
  };
331
645
  }
332
646
  if (correctiveDecision.planHash !== task.planHash) {
647
+ if (correctiveDecision.decision === 'continue-fix'
648
+ && options.recoveredDecisionEventId === correctiveDecision.eventId) {
649
+ return {
650
+ stepId,
651
+ guardedCategory,
652
+ attemptCount,
653
+ nextAttemptOrdinal,
654
+ mode: 'corrective',
655
+ correctiveDecision,
656
+ posture: 'continue-fix',
657
+ };
658
+ }
333
659
  if (correctiveDecision.decision === 'replan-required') {
334
660
  return {
335
661
  stepId,
@@ -540,6 +866,142 @@ function validateStoredCorrectiveDecisionEvent(event, task, file, line) {
540
866
  normalizeStringList(event.coveredEventIds, 'Corrective decision coveredEventIds');
541
867
  assertHash64(event.planHash, `Corrective decision planHash at ${file}:${line}`);
542
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
+ }
543
1005
  function validateRemediationAttemptOrdinals(events, task) {
544
1006
  const attemptsByStep = new Map();
545
1007
  for (const event of events) {
@@ -699,7 +1161,7 @@ function validateCorrectiveDecisionCoverage(task, decisions, remediationEvents)
699
1161
  }
700
1162
  }
701
1163
  }
702
- function validateHistoricalRemediationModes(task, stepId, remediationEvents, correctiveDecisions) {
1164
+ function validateHistoricalRemediationModes(task, stepId, remediationEvents, correctiveDecisions, recoveredEventIds) {
703
1165
  for (const event of remediationEvents) {
704
1166
  if (event.attemptOrdinal <= 2) {
705
1167
  if (event.mode !== 'ordinary') {
@@ -713,7 +1175,7 @@ function validateHistoricalRemediationModes(task, stepId, remediationEvents, cor
713
1175
  }
714
1176
  continue;
715
1177
  }
716
- if (event.mode === 'ordinary') {
1178
+ if (event.mode === 'ordinary' && !recoveredEventIds.has(event.eventId)) {
717
1179
  throw new WorkflowError('STATE_CORRUPT', 'Guarded remediation attempt ordinals above 2 must not remain ordinary.', {
718
1180
  taskId: task.id,
719
1181
  stepId,
@@ -740,13 +1202,13 @@ function validateHistoricalRemediationModes(task, stepId, remediationEvents, cor
740
1202
  }
741
1203
  }
742
1204
  }
743
- function validateHistoricalRemediationModesForAllSteps(task, remediationEvents, correctiveDecisions) {
1205
+ function validateHistoricalRemediationModesForAllSteps(task, remediationEvents, correctiveDecisions, recoveredEventIds) {
744
1206
  const stepIds = new Set(remediationEvents.map((event) => event.stepId));
745
1207
  for (const stepId of stepIds) {
746
1208
  const stepEvents = remediationEvents
747
1209
  .filter((event) => event.stepId === stepId)
748
1210
  .sort((left, right) => left.attemptOrdinal - right.attemptOrdinal);
749
- validateHistoricalRemediationModes(task, stepId, stepEvents, correctiveDecisions);
1211
+ validateHistoricalRemediationModes(task, stepId, stepEvents, correctiveDecisions, recoveredEventIds);
750
1212
  }
751
1213
  }
752
1214
  function validateCorrectiveDecisionAudit(audit) {