PR C: wire real NotaryRegistry on Chain 138 (arch step 4) (#7)
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
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
This commit was merged in pull request #7.
This commit is contained in:
174
orchestrator/src/services/stateMachine.ts
Normal file
174
orchestrator/src/services/stateMachine.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* 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],
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user