"""State machine for application transitions. Implements the transition table from docs/data-model.md. Any transition not listed raises InvalidTransition (409 in API). """ from __future__ import annotations from dataclasses import dataclass class InvalidTransition(Exception): """Raised when a state transition is not allowed.""" def __init__(self, from_state: str, to_state: str, reason: str = ""): self.from_state = from_state self.to_state = to_state self.reason = reason super().__init__( f"Invalid transition: {from_state} -> {to_state}" + (f": {reason}" if reason else "") ) # All valid states STATES = frozenset({ "discovered", "scored", "approved", "rejected", "drafting", "sent", "interviewing", "offer", "closed", "expired", }) # Transition table: (from, to) -> guard name (or None for no guard) # Guard names map to guard functions below. TRANSITIONS: dict[tuple[str, str], str | None] = { ("discovered", "scored"): "scoring_completed", ("discovered", "rejected"): None, ("scored", "approved"): None, ("scored", "rejected"): None, ("approved", "drafting"): None, ("drafting", "sent"): "confirmed_approval", ("sent", "interviewing"): None, ("interviewing", "offer"): None, ("interviewing", "closed"): None, ("offer", "closed"): None, ("scored", "expired"): None, ("approved", "expired"): None, } @dataclass class TransitionContext: """Context passed to guard functions for validation.""" application_id: str from_state: str to_state: str has_score: bool = False has_confirmed_approval: bool = False artifact_hash_match: bool = False # Guard implementations GUARDS: dict[str, callable] = { "scoring_completed": lambda ctx: ctx.has_score, "confirmed_approval": lambda ctx: ctx.has_confirmed_approval and ctx.artifact_hash_match, } def can_transition(from_state: str, to_state: str) -> bool: """Check if a transition is in the table (does not evaluate guards).""" return (from_state, to_state) in TRANSITIONS def check_transition(ctx: TransitionContext) -> None: """Validate a transition. Raises InvalidTransition if not allowed.""" if ctx.from_state not in STATES: raise InvalidTransition(ctx.from_state, ctx.to_state, f"unknown state: {ctx.from_state}") if ctx.to_state not in STATES: raise InvalidTransition(ctx.from_state, ctx.to_state, f"unknown state: {ctx.to_state}") key = (ctx.from_state, ctx.to_state) if key not in TRANSITIONS: raise InvalidTransition( ctx.from_state, ctx.to_state, f"transition {ctx.from_state} -> {ctx.to_state} is not in the transition table", ) guard_name = TRANSITIONS[key] if guard_name is not None: guard_fn = GUARDS[guard_name] if not guard_fn(ctx): raise InvalidTransition( ctx.from_state, ctx.to_state, f"guard failed: {guard_name}", )