/** * Transaction state-machine service. * * Centralized enforcement of architecture note ยง9 (state-transition rules). * The coordinator, exception manager, and any operator action must route * through `transition()` so the transition table and segregation-of-duties * matrix are applied identically everywhere. */ import { query, transaction as dbTransaction } from "../db/postgres"; import { ALLOWED_TRANSITIONS, ROLE_FOR_TRANSITION, SOD_REQUIRED_TRANSITIONS, canTransition, type ActorRole, type TransactionState, } from "../types/transactionState"; export interface TransitionRequest { planId: string; from: TransactionState; to: TransactionState; actor: string; actorRole: ActorRole; reason?: string; sourceEventId?: string; signature?: string; } export class StateTransitionError extends Error { constructor( message: string, public readonly code: | "illegal_transition" | "sod_violation" | "stale_from_state" | "terminal_state", ) { super(message); this.name = "StateTransitionError"; } } /** * Execute a state transition atomically: verify legality, enforce SoD, * update `plans.transaction_state`, and append a row to * `transaction_state_transitions`. * * Throws `StateTransitionError` if the transition is not legal or violates * segregation-of-duties. */ export async function transition(req: TransitionRequest): Promise { if (!canTransition(req.from, req.to)) { throw new StateTransitionError( `Transition ${req.from} -> ${req.to} is not in the allowed table`, "illegal_transition", ); } const key = `${req.from}->${req.to}` as const; if (SOD_REQUIRED_TRANSITIONS.has(key)) { const requiredRole = ROLE_FOR_TRANSITION[key]; if (req.actorRole !== requiredRole) { throw new StateTransitionError( `Transition ${key} requires role '${requiredRole}' but actor '${req.actor}' has role '${req.actorRole}'`, "sod_violation", ); } // SoD: the actor executing the transition must not be the same as the // actor who drove the previous human-gated transition. We enforce this // at the coordinator level by looking at the transition log. const prior = await query<{ actor: string; actor_role: ActorRole }>( `SELECT actor, actor_role FROM transaction_state_transitions WHERE plan_id = $1 AND actor_role IN ('approver','releaser','exception_manager') ORDER BY created_at DESC LIMIT 1`, [req.planId], ); if (prior.length > 0 && prior[0].actor === req.actor) { throw new StateTransitionError( `SoD violation: actor '${req.actor}' already drove the previous gated transition`, "sod_violation", ); } } await dbTransaction(async (client) => { const current = await client.query<{ transaction_state: TransactionState }>( "SELECT transaction_state FROM plans WHERE plan_id = $1 FOR UPDATE", [req.planId], ); if (current.rows.length === 0) { throw new StateTransitionError( `Plan ${req.planId} not found`, "stale_from_state", ); } if (current.rows[0].transaction_state !== req.from) { throw new StateTransitionError( `Plan ${req.planId} is in state '${current.rows[0].transaction_state}', not '${req.from}'`, "stale_from_state", ); } if (ALLOWED_TRANSITIONS[current.rows[0].transaction_state].length === 0) { throw new StateTransitionError( `Plan ${req.planId} is in terminal state '${current.rows[0].transaction_state}'`, "terminal_state", ); } await client.query( "UPDATE plans SET transaction_state = $1, updated_at = CURRENT_TIMESTAMP WHERE plan_id = $2", [req.to, req.planId], ); await client.query( `INSERT INTO transaction_state_transitions ( plan_id, from_state, to_state, reason, source_event_id, actor, actor_role, signature ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, [ req.planId, req.from, req.to, req.reason ?? null, req.sourceEventId ?? null, req.actor, req.actorRole, req.signature ?? null, ], ); }); } /** * Get the current transaction state for a plan. */ export async function getTransactionState( planId: string, ): Promise { const rows = await query<{ transaction_state: TransactionState }>( "SELECT transaction_state FROM plans WHERE plan_id = $1", [planId], ); return rows.length > 0 ? rows[0].transaction_state : null; } /** * Get the full state-transition history for a plan. */ export async function getTransitionHistory( planId: string, ): Promise< Array<{ from_state: TransactionState | null; to_state: TransactionState; reason: string | null; actor: string; actor_role: ActorRole; signature: string | null; source_event_id: string | null; created_at: Date; }> > { return await query( `SELECT from_state, to_state, reason, actor, actor_role, signature, source_event_id, created_at FROM transaction_state_transitions WHERE plan_id = $1 ORDER BY created_at ASC`, [planId], ); }