← back to Unclaimed Property Platform
services/claims/claim_workflow.py
150 lines
"""Claim workflow — explicit state machine + transactional outbox.
Package preparation is deliberately separated from state adjudication. The platform never
decides entitlement and never pays; the STATE does. Guarantees:
- explicit allowed transitions (illegal transitions raise)
- optimistic version increment
- idempotency keys on every event
- append-only event history
- transactional outbox so a transient integration failure can't double-submit to a state
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from typing import Protocol
from uuid import UUID, uuid4
class ClaimStatus(str, Enum):
DRAFT = "draft"
IDENTITY_PENDING = "identity_pending"
EVIDENCE_PENDING = "evidence_pending"
READY_FOR_SUBMISSION = "ready_for_submission"
SUBMITTING = "submitting"
SUBMITTED_TO_STATE = "submitted_to_state"
MORE_INFORMATION_REQUIRED = "more_information_required"
APPROVED = "approved"
DENIED = "denied"
PAID_BY_STATE = "paid_by_state"
CANCELLED = "cancelled"
ALLOWED_TRANSITIONS: dict[ClaimStatus, set[ClaimStatus]] = {
ClaimStatus.DRAFT: {ClaimStatus.IDENTITY_PENDING, ClaimStatus.CANCELLED},
ClaimStatus.IDENTITY_PENDING: {ClaimStatus.EVIDENCE_PENDING, ClaimStatus.CANCELLED},
ClaimStatus.EVIDENCE_PENDING: {ClaimStatus.READY_FOR_SUBMISSION, ClaimStatus.CANCELLED},
ClaimStatus.READY_FOR_SUBMISSION: {ClaimStatus.SUBMITTING, ClaimStatus.EVIDENCE_PENDING},
ClaimStatus.SUBMITTING: {ClaimStatus.SUBMITTED_TO_STATE, ClaimStatus.READY_FOR_SUBMISSION},
ClaimStatus.SUBMITTED_TO_STATE: {
ClaimStatus.MORE_INFORMATION_REQUIRED, ClaimStatus.APPROVED, ClaimStatus.DENIED},
# ...also allow the claimant to withdraw when they can't supply more info (M2).
ClaimStatus.MORE_INFORMATION_REQUIRED: {
ClaimStatus.EVIDENCE_PENDING, ClaimStatus.DENIED, ClaimStatus.CANCELLED},
ClaimStatus.APPROVED: {ClaimStatus.PAID_BY_STATE},
ClaimStatus.DENIED: set(),
ClaimStatus.PAID_BY_STATE: set(),
ClaimStatus.CANCELLED: set(),
}
# Entitlement + payment decisions belong to the STATE, never the platform. A transition
# into any of these requires a state-originated actor (id prefixed 'state:'), so an
# internal/claimant actor can never self-approve or self-pay. (Security 2.3 / Cody #2.)
STATE_ONLY_STATUSES = frozenset({
ClaimStatus.APPROVED, ClaimStatus.DENIED, ClaimStatus.PAID_BY_STATE,
})
@dataclass
class Claim:
claim_id: UUID
jurisdiction: str
public_property_reference: str
claimant_id: UUID
status: ClaimStatus
version: int
state_case_id: str | None = None
class ClaimRepository(Protocol):
def get_for_update(self, claim_id: UUID) -> Claim: ...
def save(self, claim: Claim) -> None: ...
def append_event(self, claim_id: UUID, event_type: str, payload: dict,
idempotency_key: str) -> None: ...
def add_outbox_event(self, event_type: str, aggregate_id: UUID, payload: dict) -> None: ...
class StateAdapter(Protocol):
def submit_claim(self, claim: Claim, idempotency_key: str) -> str: ...
def transition_claim(repository: ClaimRepository, claim_id: UUID, target: ClaimStatus,
actor_id: str, idempotency_key: str) -> Claim:
claim = repository.get_for_update(claim_id)
if target not in ALLOWED_TRANSITIONS[claim.status]:
raise ValueError(f"Invalid transition from {claim.status} to {target}")
if target in STATE_ONLY_STATUSES and not actor_id.startswith("state:"):
raise PermissionError(
f"{target.value} is a state entitlement decision; actor {actor_id!r} may not set it"
)
previous = claim.status
claim.status = target
claim.version += 1
repository.save(claim)
repository.append_event(
claim_id=claim.claim_id, event_type="claim_status_changed",
payload={"from": previous.value, "to": target.value,
"actor_id": actor_id, "version": claim.version},
idempotency_key=idempotency_key,
)
return claim
def queue_state_submission(repository: ClaimRepository, claim_id: UUID, actor_id: str,
idempotency_key: str) -> Claim:
claim = transition_claim(repository, claim_id, ClaimStatus.SUBMITTING,
actor_id, idempotency_key)
# Written in the SAME transaction as the claim; a worker delivers it to the state.
repository.add_outbox_event(
event_type="state_claim_submission_requested",
aggregate_id=claim.claim_id,
payload={"claim_id": str(claim.claim_id), "jurisdiction": claim.jurisdiction,
"idempotency_key": idempotency_key, "correlation_id": str(uuid4())},
)
return claim
def complete_state_submission(repository: ClaimRepository, adapter: StateAdapter,
claim_id: UUID, idempotency_key: str) -> Claim:
# Pre-flight: verify state before calling the external adapter (avoids redundant
# external calls if the claim is already past SUBMITTING). M1: route the status
# transition through transition_claim so guard logic and audit trail are consistent.
preflight = repository.get_for_update(claim_id)
if preflight.status != ClaimStatus.SUBMITTING:
raise ValueError("Claim is not awaiting submission")
external_case_id = adapter.submit_claim(claim=preflight, idempotency_key=idempotency_key)
# Persist state_case_id before transitioning so every handler sees it immediately
# once the claim reaches SUBMITTED_TO_STATE.
preflight.state_case_id = external_case_id
repository.save(preflight)
# Route through transition_claim — picks up ALLOWED_TRANSITIONS guard + STATE_ONLY
# guard + the standard 'claim_status_changed' event. transition_claim does its own
# get_for_update so optimistic locking remains in play.
# Namespace THIS transition's idempotency key. queue_state_submission already wrote a
# 'claim_status_changed' event under the bare `idempotency_key` for its SUBMITTING
# transition; reusing it here collides on UNIQUE(claim_id, idempotency_key). The base
# key is one business token spanning queue+complete, so each internal transition must
# namespace its own event (regression caught by test_cycle2_hardening).
claim = transition_claim(
repository, claim_id, ClaimStatus.SUBMITTED_TO_STATE,
actor_id="system:state-adapter", idempotency_key=f"{idempotency_key}:transition",
)
# Append the submission-specific event (namespaced key avoids collision with the
# 'claim_status_changed' event written by transition_claim — C3 fix preserved).
repository.append_event(
claim_id=claim.claim_id, event_type="claim_submitted_to_state",
payload={"state_case_id": external_case_id, "version": claim.version},
idempotency_key=f"{idempotency_key}:submitted",
)
return claim