Files
CurrenciCombo/orchestrator/src/services/stateMachine.ts
nsatoshi 3e1fb9ef7e
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
PR C: wire real NotaryRegistry on Chain 138 (arch step 4) (#7)
2026-04-22 17:11:50 +00:00

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],
);
}