Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion src/hooks/useEnhancedBondMutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 13 additions & 5 deletions src/lib/bondActionStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()}`,
Expand Down
24 changes: 21 additions & 3 deletions src/lib/mutationRecovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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
Expand Down
136 changes: 132 additions & 4 deletions src/lib/mutationStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<MutationStatus>
> = 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
// ═══════════════════════════════════════════════════════════════════════════
Expand Down Expand Up @@ -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<string, unknown>,
maxAttempts: number = DEFAULT_MAX_ATTEMPTS
maxAttempts: number = DEFAULT_MAX_ATTEMPTS,
options?: CreateMutationOptions
): { operationId: MutationOperationId; isNewOperation: boolean } {
const storage = readMutationStorage()
const requestHash = calculateRequestHash(type, params)
Expand All @@ -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 }
}

Expand All @@ -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,
Expand Down