← 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