"""Monotonic, idempotent state machine for task-produced alerts.""" from __future__ import annotations from datetime import datetime, timezone from app.domain.schemas.task_alert import TaskAlertEvent, TaskAlertState from app.domain.domain_errors import require from app.services.content_digest import digest class TaskAlertService: def __init__( self, repository, context, *, clock=None, internal_producer_id: str | None = None, ): self.repo = repository self.context = context self.clock = clock or (lambda: datetime.now(timezone.utc)) self.internal_producer_id = internal_producer_id async def push(self, command: TaskAlertEvent) -> TaskAlertState: if self.internal_producer_id is None: require( self.context is not None and self.context.project_role == "member", "PERMISSION_DENIED", "Alert events require project write permission", status=403, ) require( command.producer_id == f"principal:{self.context.user_id}", "PERMISSION_DENIED", "Alert producer must match the authenticated project principal", status=403, ) else: require( command.producer_id == self.internal_producer_id, "PERMISSION_DENIED", "Internal alert producer identity does not match", status=403, ) event = command.model_dump(mode="json") async with self.repo.transaction(): await self.repo.lock_alert(command.alert_id) prior_event = await self.repo.get_event( command.producer_id, str(command.event_id) ) if prior_event is not None: prior_command = { key: value for key, value in prior_event.items() if key != "resulting_state" } require( digest(prior_command) == digest(event), "IDEMPOTENCY_CONFLICT", "event_id was used with a different alert event", status=409, ) return TaskAlertState.model_validate(prior_event["resulting_state"]) current = await self.repo.get_alert(command.alert_id) state = self._transition(command, current) state_document = state.model_dump(mode="json") await self.repo.save_event(event, state_document) await self.repo.save_alert(state_document) return state def _transition( self, command: TaskAlertEvent, current: tuple[int, dict] | None ) -> TaskAlertState: now = self.clock() require(now.tzinfo is not None, "SCHEMA_INVALID", "Alert clock must be timezone-aware") if current is None: require(command.event_type == "raised", "INVALID_TRANSITION", "An alert must be raised before it can change state", status=409) revision = 1 status = "active" else: revision, document = current require(document.get("producer_id") == command.producer_id, "PERMISSION_DENIED", "Only the originating producer can update an alert", status=403) require(document.get("source") == command.source.model_dump(mode="json"), "SCOPE_MISMATCH", "Alert source cannot change", status=409) require( all( document.get(field) == event_value for field, event_value in { "condition_id": command.condition_id, "title": command.title, "description": command.description, "occurred_at": command.model_dump(mode="json")["occurred_at"], "severity": command.severity, }.items() ), "IDEMPOTENCY_CONFLICT", "Alert facts cannot change after the alert is raised", status=409, ) allowed = { ("active", "acknowledged"): "acknowledged", ("active", "resolved"): "resolved", ("acknowledged", "resolved"): "resolved", } status = allowed.get((document["status"], command.event_type)) require(status is not None, "INVALID_TRANSITION", "Alert state transition is not permitted", status=409) revision += 1 return TaskAlertState( alert_id=command.alert_id, revision=revision, producer_id=command.producer_id, condition_id=command.condition_id, source=command.source, title=command.title, description=command.description, occurred_at=command.occurred_at, severity=command.severity, status=status, updated_at=now, )