Some checks failed
CI / Frontend Lint (push) Failing after 6s
CI / Frontend Type Check (push) Failing after 6s
CI / Frontend Build (push) Failing after 6s
CI / Frontend E2E Tests (push) Failing after 8s
CI / Contracts Compile (push) Has been cancelled
CI / Contracts Test (push) Has been cancelled
CI / Orchestrator Build (push) Has been cancelled
Security Scan / OWASP ZAP Scan (push) Has been cancelled
Security Scan / Dependency Vulnerability Scan (push) Has been cancelled
175 lines
5.1 KiB
TypeScript
175 lines
5.1 KiB
TypeScript
/**
|
|
* 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<void> {
|
|
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<TransactionState | null> {
|
|
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],
|
|
);
|
|
}
|