From 608477f30ca3b419b05dfb1e3f507fa5109ce228 Mon Sep 17 00:00:00 2001 From: Fury03 Date: Mon, 31 Aug 2026 17:28:50 +0100 Subject: [PATCH] feat(mutations): enforce state-transition invariants and fix migration paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a formal state-transition matrix to the mutation storage layer that prevents illegal status transitions (e.g. success → error, idle → submitting). updateMutationOperation now validates every status change against the matrix and rejects violations, guaranteeing that terminal states (success, cancelled) are immutable and no partial state can persist. Key changes: - Add LEGAL_TRANSITIONS matrix and validateStateTransition() to mutationStorage - Guard updateMutationOperation to reject illegal transitions - Add migrationStatus option to createMutationOperation for historical reconstruction of legacy records that are already in terminal states - Fix migrateRecordToMutationSystem to use migrationStatus instead of illegally transitioning pending → success - Fix recoverOperation to transition idle → pending before setting error - Fix executeOperationAttempt to transition error/idle → pending before submitting (matrix requires error → pending → submitting) - Fix reset() to unlink legacy records without modifying terminal states 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- src/hooks/useEnhancedBondMutations.ts | 8 +- src/lib/bondActionStorage.ts | 18 +++- src/lib/mutationRecovery.ts | 24 ++++- src/lib/mutationStorage.ts | 136 +++++++++++++++++++++++++- 4 files changed, 173 insertions(+), 13 deletions(-) diff --git a/src/hooks/useEnhancedBondMutations.ts b/src/hooks/useEnhancedBondMutations.ts index a5a6ff2c..b0045192 100644 --- a/src/hooks/useEnhancedBondMutations.ts +++ b/src/hooks/useEnhancedBondMutations.ts @@ -365,15 +365,21 @@ export function useEnhancedBondMutations(): UseEnhancedBondMutationsResult { ]) const reset = useCallback((): void => { - // Reset both legacy and unified storage to idle state + // Reset legacy storage to idle state. The unified mutation system tracks + // terminal states (success, cancelled) as immutable — we only unlink the + // legacy record here so the UI returns to an idle presentation. updateBondAction('create', () => ({ status: 'idle', attempts: 0, + operationId: undefined, + migratedToV2: false, })) updateBondAction('withdraw', () => ({ status: 'idle', attempts: 0, + operationId: undefined, + migratedToV2: false, })) activeOperationRef.current = null diff --git a/src/lib/bondActionStorage.ts b/src/lib/bondActionStorage.ts index 13f5baaf..56bdf2a0 100644 --- a/src/lib/bondActionStorage.ts +++ b/src/lib/bondActionStorage.ts @@ -144,13 +144,21 @@ function migrateRecordToMutationSystem( try { const mutationType = kind === 'create' ? 'bond_create' : 'bond_withdraw' const params = record.lastRequest || {} + const targetStatus = mapLegacyStatusToMutation(record.status) + + // Create operation in the unified system. For terminal states (success, + // cancelled) that cannot be reached through idle → pending → …, we pass + // migrationStatus so the operation is created directly in the target state. + // This is the only code path that bypasses the normal lifecycle — all + // other callers must go through idle. + const { operationId } = createMutationOperation(mutationType, params, 3, { + migrationStatus: targetStatus, + }) - // Create operation in the unified system - const { operationId } = createMutationOperation(mutationType, params, 3) - - // Update the operation to match the legacy state + // Fill in the attempt history to reconstruct the legacy state. The + // operation was created with the correct status via migrationStatus, + // so no further status transitions are needed. const operation = updateMutationOperation(operationId, (op) => ({ - status: mapLegacyStatusToMutation(record.status), attempts: [ { attemptId: `legacy:${kind}:${Date.now()}`, diff --git a/src/lib/mutationRecovery.ts b/src/lib/mutationRecovery.ts index 768ee2e5..262c3cb5 100644 --- a/src/lib/mutationRecovery.ts +++ b/src/lib/mutationRecovery.ts @@ -408,11 +408,16 @@ export class MutationRecoveryEngine { error: error instanceof Error ? error.message : String(error), }) - // Mark as error state for user visibility - updateMutationOperation(operationId, () => ({ + // Mark as error state for user visibility. Idle operations must go + // through 'pending' first (idle → pending → error) to satisfy the + // state-transition matrix. + if (operation.status === 'idle') { + updateMutationOperation(operationId, () => ({ status: 'pending' })) + } + updateMutationOperation(operationId, (op) => ({ status: 'error', attempts: [ - ...operation.attempts, + ...op.attempts, { attemptId: `recovery:${Date.now()}`, timestamp: new Date().toISOString(), @@ -556,6 +561,19 @@ export class MutationRecoveryEngine { const operation = getMutationOperation(operationId) || opParam if (!operation || operation.status === 'cancelled') return false + // If the operation is in 'error' or 'idle', transition through 'pending' + // first so we never jump directly to 'submitting' — the state matrix + // requires error → pending → submitting. + if (operation.status === 'error' || operation.status === 'idle') { + const transitioned = updateMutationOperation(operationId, () => ({ + status: 'pending', + })) + if (!transitioned || transitioned.status !== 'pending') { + logWarn('mutation_recovery_transition_to_pending_failed', { operationId }) + return false + } + } + const attemptId = `attempt:${Date.now()}:${Math.random().toString(36).substr(2, 9)}` // Mark as submitting and create new attempt record diff --git a/src/lib/mutationStorage.ts b/src/lib/mutationStorage.ts index b372ccee..9764bc09 100644 --- a/src/lib/mutationStorage.ts +++ b/src/lib/mutationStorage.ts @@ -150,6 +150,78 @@ const DEFAULT_MAX_ATTEMPTS = 3 const STALE_OPERATION_MS = 24 * 60 * 60 * 1000 // 24 hours +// ═══════════════════════════════════════════════════════════════════════════ +// State-Transition Matrix +// ═══════════════════════════════════════════════════════════════════════════ +// +// Every bond and trust-score mutation follows the lifecycle: +// +// idle ──▶ pending ──▶ submitting ──▶ success +// │ │ +// ▼ ▼ +// error ◀───── error +// │ +// ▼ +// pending (retry) +// +// Terminal states (no outgoing edges): success, cancelled. +// The matrix is authoritative: any transition not listed here is rejected +// by `updateMutationOperation` and the mutation system will not persist +// unauthorized or partial state. + +/** + * Legal state-transition matrix for `MutationStatus`. + * + * Keys are source statuses; values are the set of allowed target statuses. + * An empty set means the state is terminal (no outgoing transitions). + * The identity transition (status → same status) is always allowed. + */ +export const LEGAL_TRANSITIONS: ReadonlyMap< + MutationStatus, + ReadonlySet +> = new Map([ + // idle: a freshly created operation can only begin execution + ['idle', new Set(['pending'])], + + // pending: queued for execution; can start, fail validation, or be cancelled + ['pending', new Set(['submitting', 'error', 'cancelled'])], + + // submitting: in-flight; can succeed, fail permanently, retry (back to pending), or cancel + ['submitting', new Set(['success', 'error', 'pending', 'cancelled'])], + + // success: terminal – no outgoing transitions allowed + ['success', new Set()], + + // error: failed; can be retried (back to pending) or cancelled + ['error', new Set(['pending', 'cancelled'])], + + // cancelled: terminal – no outgoing transitions allowed + ['cancelled', new Set()], +]) + +/** + * Validates whether a state transition is legal under the invariant matrix. + * + * @returns `null` if the transition is legal (or a no-op), or a human-readable + * violation message if it is illegal. + */ +export function validateStateTransition( + from: MutationStatus, + to: MutationStatus, +): string | null { + // Identity transitions (no actual change) are always valid + if (from === to) return null + + const allowed = LEGAL_TRANSITIONS.get(from) + if (!allowed) { + return `Unknown source status: ${from}` + } + if (!allowed.has(to)) { + return `Illegal state transition: ${from} → ${to}` + } + return null +} + // ═══════════════════════════════════════════════════════════════════════════ // Storage Operations // ═══════════════════════════════════════════════════════════════════════════ @@ -439,13 +511,34 @@ function cleanupStaleOperations(storage: MutationStorageV2): MutationStorageV2 { return storage } +/** + * Options for creating a mutation operation. + */ +export interface CreateMutationOptions { + maxAttempts?: number + /** + * Optional initial status for migration/historical reconstruction. + * When provided, the operation is created directly in the given status, + * bypassing the normal idle-start lifecycle. This is *only* intended for + * migrating legacy records whose terminal state (e.g. `success`) cannot + * be reached through the standard idle → pending → … transition chain. + * Normal code paths must NOT use this option. + */ + migrationStatus?: MutationStatus +} + /** * Creates a new mutation operation with deduplication check. + * + * @param options.migrationStatus - When set, the operation is created directly + * in the given status (bypassing the transition matrix). Intended only for + * migrating legacy records that are already in a terminal state. */ export function createMutationOperation( type: MutationType, params: Record, - maxAttempts: number = DEFAULT_MAX_ATTEMPTS + maxAttempts: number = DEFAULT_MAX_ATTEMPTS, + options?: CreateMutationOptions ): { operationId: MutationOperationId; isNewOperation: boolean } { const storage = readMutationStorage() const requestHash = calculateRequestHash(type, params) @@ -464,25 +557,37 @@ export function createMutationOperation( return { operationId: existingOperation.operationId, isNewOperation: false } } + // Determine initial status: migration callers may supply a terminal status + // that cannot be reached through the normal idle-start lifecycle. + const initialStatus: MutationStatus = + options?.migrationStatus && LEGAL_TRANSITIONS.has(options.migrationStatus) + ? options.migrationStatus + : 'idle' + // Create new operation const operationId = generateOperationId(type, requestHash) const operation: MutationOperation = { operationId, type, - status: 'idle', + status: initialStatus, requestHash, requestMetadata: { ...params }, attempts: [], maxAttempts, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), - isRecovered: false, + isRecovered: initialStatus !== 'idle', } storage.operations[operationId] = operation writeMutationStorage(storage) - logInfo('mutation_operation_created', { operationId, type, requestHash }) + logInfo('mutation_operation_created', { + operationId, + type, + requestHash, + initialStatus, + }) return { operationId, isNewOperation: true } } @@ -502,6 +607,29 @@ export function updateMutationOperation( } const updates = updater(operation) + + // ── Enforce state-transition invariants ───────────────────────────────── + // If the updater changes the status field, the transition must be legal. + // Illegal transitions are rejected: the original (unmodified) operation is + // returned so callers can still read the current state, but nothing is + // persisted. This guarantees: + // • Terminal states (success, cancelled) never leave their state. + // • No partial or unauthorized state can survive across any path. + if (updates.status !== undefined && updates.status !== operation.status) { + const violation = validateStateTransition(operation.status, updates.status) + if (violation) { + logWarn('mutation_state_transition_violation', { + operationId, + type: operation.type, + from: operation.status, + to: updates.status, + violation, + }) + // Return the unmodified operation — nothing is persisted + return operation + } + } + const updatedOperation: MutationOperation = { ...operation, ...updates,