"""Plan Lifecycle Service for CleverAgents v3.

The ``PlanLifecycleService`` manages the v3 four-phase plan lifecycle:

```
Action -> Strategize -> Execute -> Apply
```

Plans are instantiated from Action templates. The Action phase is the
non-processing template phase; ``plan use`` transitions to Strategize.
Terminal outcomes live in Apply's processing state: ``applied``,
``constrained``, ``errored``, or ``cancelled``.

## Phase Transitions

| Method                | From                 | To               |
|-----------------------|----------------------|------------------|
| ``use_action(...)``   | Action (available)   | Strategize/QUEUED|
| ``execute_plan(...)`` | Strategize/COMPLETE  | Execute/QUEUED   |
| ``apply_plan(...)``   | Execute/COMPLETE     | Apply/QUEUED     |

## Apply Terminal Outcomes

| Processing State | Meaning                               | Next Step           |
|-----------------|---------------------------------------|---------------------|
| ``APPLIED``     | Success -- changes committed          | Done                |
| ``CONSTRAINED`` | Cannot proceed within constraints     | May revert to Strategize |
| ``ERRORED``     | Failed                                | May retry or abandon|
| ``CANCELLED``   | User/system cancelled                 | Done                |

## Persistence (Dual-Mode)

| Mode       | UnitOfWork | Storage                                     |
|------------|-----------|----------------------------------------------|
| In-memory  | ``None``  | ``self._actions`` / ``self._plans`` dicts    |
| Persisted  | provided  | DB via repositories **plus** in-memory cache |

In persisted mode the in-memory dicts act as a write-through cache:
mutations are written to DB first, then the cache is updated.

## Custom Exceptions

| Exception                       | Description                            |
|---------------------------------|----------------------------------------|
| ``InvalidPhaseTransitionError`` | Attempted invalid phase transition     |
| ``ActionNotAvailableError``     | Action not in ``available`` state      |
| ``PlanNotReadyError``           | Plan not in expected state             |

Based on ``docs/specification.md`` and implementation plan Stage A3.
"""

from __future__ import annotations

from contextlib import suppress
from datetime import datetime
from typing import TYPE_CHECKING, Any

import structlog
from ulid import ULID

from cleveragents.application.services.config_service import ConfigLevel, ConfigService
from cleveragents.application.services.plan_preflight_guardrail import (
    PlanPreflightGuardrail,
)
from cleveragents.core.exceptions import (
    BusinessRuleViolation,
    DatabaseError,
    NotFoundError,
    PlanError,
    ValidationError,
)
from cleveragents.domain.models.core.action import Action, ActionArgument, ActionState
from cleveragents.domain.models.core.actor import Actor
from cleveragents.domain.models.core.async_job import AsyncJob, serialize_job_payload
from cleveragents.domain.models.core.automation_profile import (
    BUILTIN_PROFILES,
    AutomationProfile,
)
from cleveragents.domain.models.core.decision import DecisionType
from cleveragents.domain.models.core.plan import (
    AutomationProfileProvenance,
    AutomationProfileRef,
    InvariantSource,
    NamespacedName,
    Plan,
    PlanIdentity,
    PlanInvariant,
    PlanPhase,
    PlanTimestamps,
    ProcessingState,
    ProjectLink,
    can_transition,
)
from cleveragents.infrastructure.events.models import DomainEvent
from cleveragents.infrastructure.events.types import EventType

if TYPE_CHECKING:
    from cleveragents.application.services.async_worker import InMemoryJobStore
    from cleveragents.application.services.decision_service import DecisionService
    from cleveragents.application.services.error_pattern_service import (
        ErrorPatternService,
    )
    from cleveragents.application.services.invariant_service import InvariantService
    from cleveragents.config.settings import Settings
    from cleveragents.infrastructure.database.unit_of_work import UnitOfWork
    from cleveragents.infrastructure.events.protocol import EventBus

logger = structlog.get_logger(__name__)


class InvalidPhaseTransitionError(BusinessRuleViolation):
    """Raised when an invalid phase transition is attempted."""

    def __init__(
        self,
        from_phase: PlanPhase,
        to_phase: PlanPhase,
        message: str | None = None,
    ):
        if not message:
            message = (
                f"Invalid phase transition from {from_phase.value} to {to_phase.value}"
            )
        super().__init__(message)
        self.from_phase = from_phase
        self.to_phase = to_phase


class ActionNotAvailableError(BusinessRuleViolation):
    """Raised when trying to use an action that is not available."""

    def __init__(self, action_name: str, state: ActionState):
        super().__init__(
            f"Action {action_name} is not available (state: {state.value}). "
            "Only actions in 'available' state can be used."
        )
        self.action_name = action_name
        self.state = state


class PlanNotReadyError(BusinessRuleViolation):
    """Raised when trying to transition a plan that is not ready."""

    def __init__(self, plan_id: str, phase: PlanPhase, state: ProcessingState):
        super().__init__(
            f"Plan {plan_id} is not ready for transition. "
            f"Current phase: {phase.value}, state: {state.value}. "
            "Plan must be in 'complete' state to transition."
        )
        self.plan_id = plan_id
        self.phase = phase
        self.state = state


class ReconciliationBlockedError(BusinessRuleViolation):
    """Raised when invariant reconciliation fails, blocking a transition.

    The Invariant Reconciliation Actor encountered an error during
    execution, preventing the plan from proceeding to the next phase.
    """

    def __init__(self, plan_id: str, phase: PlanPhase, reason: str):
        super().__init__(
            f"Invariant reconciliation failed for plan {plan_id} "
            f"during {phase.value} phase: {reason}"
        )
        self.plan_id = plan_id
        self.phase = phase
        self.reason = reason


class PlanLifecycleService:
    """Service for managing v3 plan lifecycle.

    This service handles:
    - Creating actions (reusable plan templates)
    - Using actions on projects to create plans (Action -> Strategize)
    - Transitioning plans through phases (Strategize -> Execute -> Apply)
    - Apply terminal outcomes: applied, constrained, errored, cancelled
    - Validating phase transitions
    """

    def __init__(
        self,
        settings: Settings,
        unit_of_work: UnitOfWork | None = None,
        decision_service: DecisionService | None = None,
        event_bus: EventBus | None = None,
        job_store: InMemoryJobStore | None = None,
        error_pattern_service: ErrorPatternService | None = None,
        config_service: ConfigService | None = None,
        invariant_service: InvariantService | None = None,
    ):
        """Initialize the plan lifecycle service.

        Args:
            settings: Application settings
            unit_of_work: Unit of Work for database transactions.
                When provided, actions and plans are persisted via
                ``ActionRepository`` and ``LifecyclePlanRepository``.
                When ``None``, the service falls back to in-memory
                storage for backward compatibility with existing tests.
            decision_service: Optional :class:`DecisionService` for
                automatically recording decisions during phase
                transitions.  When ``None``, decision recording is
                silently skipped.
            event_bus: Optional
                :class:`~cleveragents.infrastructure.events.protocol.EventBus`
                for emitting plan lifecycle events.  When ``None``,
                event emission is silently skipped.
            job_store: Optional :class:`InMemoryJobStore` for enqueuing
                async jobs when ``settings.async_enabled`` is True.
                When ``None``, async job creation is silently skipped
                even if async is enabled.
            error_pattern_service: Optional
                :class:`ErrorPatternService` for Layer 4 Predictive
                Error Prevention.  When provided, the service is
                consulted before the Execute phase to inject preventive
                guidance.  When ``None``, predictive prevention is
                silently skipped.
            invariant_service: Optional
                :class:`InvariantService` for invariant reconciliation
                during phase transitions.  When provided and the plan
                has an ``invariant_actor`` configured, the Invariant
                Reconciliation Actor is auto-invoked at each phase
                transition to verify invariants hold.  When ``None``,
                reconciliation is silently skipped.
        """
        self.settings = settings
        self.unit_of_work = unit_of_work
        self.decision_service = decision_service
        self.event_bus = event_bus
        self._job_store = job_store
        self.error_pattern_service = error_pattern_service
        self._config_service = config_service
        self.invariant_service = invariant_service
        self._logger = logger.bind(service="plan_lifecycle")
        self.preflight_guardrail = PlanPreflightGuardrail()
        self._subscribe_correction_reconciliation()

        # In-memory fallback storage (used only when no UoW is provided)
        self._actions: dict[str, Action] = {}
        self._plans: dict[str, Plan] = {}

    @property
    def _persisted(self) -> bool:
        """Return True when a UnitOfWork is wired for persistence."""
        return self.unit_of_work is not None

    def _try_record_decision(
        self,
        plan_id: str,
        decision_type: str,
        question: str,
        chosen_option: str,
        parent_decision_id: str | None = None,
    ) -> None:
        """Record a decision if DecisionService is available.

        Failures are logged but never propagated — decision recording
        must not block lifecycle transitions.
        """
        if self.decision_service is None:
            return

        try:
            self.decision_service.record_decision(
                plan_id=plan_id,
                decision_type=decision_type,
                question=question,
                chosen_option=chosen_option,
                parent_decision_id=parent_decision_id,
            )
        except Exception:
            self._logger.warning(
                "decision_recording_failed",
                plan_id=plan_id,
                decision_type=decision_type,
                exc_info=True,
            )

    def _consult_error_patterns(self, plan: Plan) -> None:
        """Consult the Error Pattern Database before execution.

        When an ``ErrorPatternService`` is configured, the plan's
        description is matched against known error patterns.  Any
        resulting preventive guidance is stored in the plan's
        ``error_details`` dict under the ``preventive_guidance`` key
        so that downstream actors can incorporate it.

        Failures are logged but never propagated — preventive guidance
        must not block lifecycle transitions.
        """
        if self.error_pattern_service is None:
            return

        try:
            context_text = plan.description or ""
            guidance = self.error_pattern_service.match_patterns(context_text)
            if guidance.has_guidance:
                formatted = guidance.format_for_context()
                existing = dict(plan.error_details) if plan.error_details else {}
                existing["preventive_guidance"] = formatted
                plan.error_details = existing
                self._logger.info(
                    "Preventive guidance injected",
                    plan_id=plan.identity.plan_id,
                    matched_patterns=len(guidance.matched_patterns),
                    checks=len(guidance.preventive_checks),
                )
        except Exception:
            self._logger.warning(
                "error_pattern_consultation_failed",
                plan_id=plan.identity.plan_id,
                exc_info=True,
            )

    def _run_estimation(self, plan: Plan) -> None:
        """Run the estimation actor if configured on the plan.

        Currently uses a stub implementation.  When
        ``plan.estimation_actor`` is set (and is not the
        ``__optional__`` placeholder), invokes the estimation stub
        actor to produce an ``EstimationResult``.  The result is stored
        on the plan and persisted.

        On success a ``PLAN_ESTIMATION_COMPLETE`` domain event is emitted
        and ``plan.cost_estimate_usd`` is populated from the midpoint of
        the estimated cost.

        Estimation is informational only — failures are logged but
        never block the Execute transition.
        """
        actor_name = plan.estimation_actor
        if not actor_name or actor_name == "__optional__":
            return

        try:
            from cleveragents.application.services.plan_executor import (
                EstimationStubActor,
            )

            # TODO: Replace EstimationStubActor with real actor dispatch
            # via actor registry
            stub = EstimationStubActor()
            result = stub.estimate(plan.identity.plan_id)
            plan.estimation_result = result
            # Populate cost_estimate_usd from the estimation result midpoint
            if result.estimated_cost_usd is not None:
                plan.cost_estimate_usd = result.estimated_cost_usd
            plan.timestamps.updated_at = datetime.now()
            self._commit_plan(plan)
            self._logger.info(
                "Estimation completed",
                plan_id=plan.identity.plan_id,
                estimation_actor=actor_name,
                summary=result.summary,
            )
            # Emit PLAN_ESTIMATION_COMPLETE domain event
            if self.event_bus is not None:
                try:
                    self.event_bus.emit(
                        DomainEvent(
                            event_type=EventType.PLAN_ESTIMATION_COMPLETE,
                            plan_id=plan.identity.plan_id,
                            details={
                                "estimation_actor": actor_name,
                                "summary": result.summary,
                                "estimated_cost_usd": result.estimated_cost_usd,
                            },
                        )
                    )
                except Exception:
                    self._logger.warning(
                        "event_bus_emit_failed",
                        event_type="PLAN_ESTIMATION_COMPLETE",
                        plan_id=plan.identity.plan_id,
                        exc_info=True,
                    )
        except Exception:
            self._logger.warning(
                "estimation_actor_failed",
                plan_id=plan.identity.plan_id,
                estimation_actor=actor_name,
                exc_info=True,
            )

    def _run_invariant_reconciliation(self, plan: Plan) -> None:
        """Run the Invariant Reconciliation Actor if configured.

        Invoked at phase transitions (start of Strategize, before
        Execute, before Apply, and after corrections) to verify that
        plan invariants hold.

        Unlike ``_run_estimation`` which is informational-only, a
        reconciliation failure **blocks** the phase transition and
        raises ``ReconciliationBlockedError``.  This ensures invariant
        violations are surfaced immediately.

        The reconciliation actor is skipped when:
        - ``invariant_service`` is ``None``
        - ``decision_service`` is ``None``
        - ``plan.invariant_actor`` is ``None`` or ``"__optional__"``

        Args:
            plan: The plan to reconcile invariants for.

        Raises:
            ReconciliationBlockedError: If reconciliation encounters an
                error, blocking the phase transition.
        """
        # Guard: skip when services are not wired or actor is disabled
        if self.invariant_service is None or self.decision_service is None:
            return

        actor_name = plan.invariant_actor
        if not actor_name or actor_name == "__optional__":
            return

        plan_id = plan.identity.plan_id

        try:
            from cleveragents.actor.reconciliation import (
                InvariantReconciliationActor,
                ReconciliationResult,
            )

            reconciliation_actor = InvariantReconciliationActor(
                invariant_service=self.invariant_service,
                decision_service=self.decision_service,
            )

            # Derive project and action names from the plan
            project_name: str | None = None
            if plan.project_links:
                project_name = plan.project_links[0].project_name

            result: ReconciliationResult = reconciliation_actor.run(
                plan_id=plan_id,
                project_name=project_name,
                action_name=plan.action_name,
                parent_decision_id=plan.decision_root_id,
            )

            self._logger.info(
                "invariant_reconciliation_complete",
                plan_id=plan_id,
                phase=plan.phase.value,
                effective_count=len(result.reconciled_set.invariants),
                conflict_count=len(result.conflicts),
                decision_count=len(result.enforced_decision_ids),
            )

            # Emit INVARIANT_RECONCILED event on success
            if self.event_bus is not None:
                try:
                    self.event_bus.emit(
                        DomainEvent(
                            event_type=EventType.INVARIANT_RECONCILED,
                            plan_id=plan_id,
                            actor_name=actor_name,
                            project_name=project_name,
                            details={
                                "phase": plan.phase.value,
                                "effective_count": len(
                                    result.reconciled_set.invariants
                                ),
                                "conflict_count": len(result.conflicts),
                                "enforced_decision_ids": (result.enforced_decision_ids),
                            },
                        )
                    )
                except Exception:
                    self._logger.warning(
                        "event_bus_emit_failed",
                        event_type="INVARIANT_RECONCILED",
                        plan_id=plan_id,
                        exc_info=True,
                    )

        except Exception as exc:
            self._logger.error(
                "invariant_reconciliation_failed",
                plan_id=plan_id,
                phase=plan.phase.value,
                invariant_actor=actor_name,
                exc_info=True,
            )

            # Emit INVARIANT_VIOLATED event on failure
            if self.event_bus is not None:
                try:
                    self.event_bus.emit(
                        DomainEvent(
                            event_type=EventType.INVARIANT_VIOLATED,
                            plan_id=plan_id,
                            actor_name=actor_name,
                            details={
                                "phase": plan.phase.value,
                                "error": str(exc),
                            },
                        )
                    )
                except Exception:
                    self._logger.warning(
                        "event_bus_emit_failed",
                        event_type="INVARIANT_VIOLATED",
                        plan_id=plan_id,
                        exc_info=True,
                    )

            raise ReconciliationBlockedError(
                plan_id=plan_id,
                phase=plan.phase,
                reason=str(exc),
            ) from exc

    def _subscribe_correction_reconciliation(self) -> None:
        """Subscribe to CORRECTION_APPLIED events for post-correction reconciliation.

        When a correction is successfully applied, invariant
        reconciliation is re-run on the affected plan to verify that
        the correction did not break any invariants.

        Post-correction reconciliation is best-effort: failures are
        logged but do not reverse the correction.
        """
        if self.event_bus is None:
            return

        try:
            self.event_bus.subscribe(
                EventType.CORRECTION_APPLIED,
                self._handle_correction_applied,
            )
        except Exception:
            self._logger.warning(
                "correction_reconciliation_subscription_failed",
                exc_info=True,
            )

    def _handle_correction_applied(self, event: DomainEvent) -> None:
        """Handle a CORRECTION_APPLIED event by re-running reconciliation.

        This is a best-effort operation — failures are logged but
        never re-raised, since the correction has already been applied
        and cannot be undone from this handler.
        """
        plan_id = event.plan_id
        if not plan_id:
            return

        try:
            plan = self.get_plan(plan_id)
            # Run reconciliation; suppress ReconciliationBlockedError
            # since we cannot undo the correction from here.
            self._run_invariant_reconciliation(plan)
        except ReconciliationBlockedError:
            self._logger.warning(
                "post_correction_reconciliation_blocked",
                plan_id=plan_id,
                exc_info=True,
            )
        except Exception:
            self._logger.warning(
                "post_correction_reconciliation_failed",
                plan_id=plan_id,
                exc_info=True,
            )

    def _persist_action_create(self, action: Action, ctx: Any) -> None:
        """Persist a new action via the repository.

        Args:
            action: The Action domain object to persist.
            ctx: A ``UnitOfWorkContext`` instance (typed as Any to avoid
                circular imports with the infrastructure layer).
        """
        ctx.actions.create(action)

    def _persist_action_update(self, action: Action, ctx: Any) -> None:
        """Persist an action update via the repository.

        Args:
            action: The Action domain object to update.
            ctx: A ``UnitOfWorkContext`` instance.
        """
        ctx.actions.update(action)

    def _persist_plan_create(self, plan: Plan, ctx: Any) -> None:
        """Persist a new plan via the repository.

        Args:
            plan: The Plan domain object to persist.
            ctx: A ``UnitOfWorkContext`` instance.
        """
        ctx.lifecycle_plans.create(plan)

    def _persist_plan_update(self, plan: Plan, ctx: Any) -> None:
        """Persist a plan update via the repository.

        Args:
            plan: The Plan domain object to update.
            ctx: A ``UnitOfWorkContext`` instance.
        """
        ctx.lifecycle_plans.update(plan)

    def _commit_plan(self, plan: Plan) -> None:
        """Persist a plan update if persistence is enabled.

        Convenience wrapper that opens a transaction, updates, and commits.
        """
        if self._persisted and self.unit_of_work is not None:
            with self.unit_of_work.transaction() as ctx:
                self._persist_plan_update(plan, ctx)

    def save_plan(self, plan: Plan) -> None:
        """Persist updates to an existing plan.

        Public convenience method for callers that need to persist
        in-memory mutations applied after initial plan creation
        (e.g., CLI overrides set after ``use_action`` returns).

        This is a no-op when persistence is not enabled (in-memory mode).
        """
        self._commit_plan(plan)

    def _maybe_enqueue_async_job(self, plan_id: str, phase: str) -> AsyncJob | None:
        """Create and enqueue an async job if async execution is enabled.

        Checks ``settings.async_enabled`` and the presence of a job
        store.  When both conditions are met, creates an ``AsyncJob``
        for the given plan phase and adds it to the store so the
        ``AsyncWorker`` poll loop can pick it up.

        Args:
            plan_id: The plan ULID.
            phase: The plan phase (``"strategize"``, ``"execute"``,
                or ``"apply"``).

        Returns:
            The created ``AsyncJob`` if enqueued, or ``None`` if async
            is disabled or no job store is configured.
        """
        if not self.settings.async_enabled:
            return None
        if self._job_store is None:
            return None

        job_id = self._generate_ulid()
        payload = serialize_job_payload(plan_id, phase)
        job = AsyncJob(
            job_id=job_id,
            plan_id=plan_id,
            phase=phase,
            payload_json=payload,
        )
        self._job_store.add(job)
        self._logger.info(
            "Async job enqueued",
            job_id=job_id,
            plan_id=plan_id,
            phase=phase,
        )
        return job

    def update_error_details(
        self,
        plan_id: str,
        error_details: dict[str, str],
    ) -> None:
        """Merge *error_details* into the plan and persist.

        This is the public API for external services (e.g.
        ``ErrorRecoveryService``) to attach structured error metadata
        to a plan without reaching into private persistence methods.

        Args:
            plan_id: The plan to update.
            error_details: Key/value pairs to merge into the plan's
                ``error_details`` dict.
        """
        plan = self.get_plan(plan_id)
        existing = dict(plan.error_details) if plan.error_details else {}
        existing.update(error_details)
        plan.error_details = existing
        plan.timestamps.updated_at = datetime.now()
        self._commit_plan(plan)

    def _generate_ulid(self) -> str:
        """Generate a new ULID string."""
        return str(ULID())

    def _resolve_actor_registry_entry(self, actor_name: str) -> object | None:
        """Resolve a namespaced actor name to its stored configuration payload."""
        if not actor_name or actor_name.startswith("__"):
            return None
        if self.unit_of_work is None:
            return None

        try:
            with self.unit_of_work.transaction() as ctx:
                actor: Actor | None = ctx.actors.get_by_name(actor_name)
        except Exception:
            self._logger.warning(
                "actor_registry_resolution_failed",
                actor_name=actor_name,
                exc_info=True,
            )
            return None

        if actor is None:
            return None
        return actor.config_blob if isinstance(actor.config_blob, dict) else None

    # Action Management

    def create_action(
        self,
        name: str,
        description: str,
        definition_of_done: str,
        strategy_actor: str,
        execution_actor: str,
        long_description: str | None = None,
        arguments: list[ActionArgument] | None = None,
        reusable: bool = True,
        read_only: bool = False,
        review_actor: str | None = None,
        estimation_actor: str | None = None,
        invariant_actor: str | None = None,
        automation_profile: str | None = None,
        invariants: list[str] | None = None,
        created_by: str | None = None,
        tags: list[str] | None = None,
    ) -> Action:
        """Create a new action (reusable plan template).

        Per spec, actions are created in ``available`` state (no draft).

        Args:
            name: Namespaced name (e.g., 'local/code-coverage')
            description: Short description of the action (required)
            definition_of_done: Completion criteria
            strategy_actor: Actor for Strategize phase
            execution_actor: Actor for Execute phase
            long_description: Detailed description
            arguments: List of action arguments
            reusable: Whether action remains available after use
            read_only: Whether action only performs read operations
            review_actor: Optional actor for code review
            estimation_actor: Optional actor for cost estimation
            invariant_actor: Optional Invariant Reconciliation Actor
            automation_profile: Default automation profile for plans
            invariants: Constraints carried forward when the action is used
            created_by: User/session that created action
            tags: Tags for organization

        Returns:
            The created Action

        Raises:
            ValidationError: If name format is invalid
        """
        self._logger.info("Creating action", name=name)

        # Parse namespaced name
        try:
            namespaced_name = NamespacedName.parse(name)
        except ValueError as e:
            raise ValidationError(f"Invalid action name: {e}") from e

        action_name_str = str(namespaced_name)

        action = Action(
            namespaced_name=namespaced_name,
            description=description,
            long_description=long_description,
            definition_of_done=definition_of_done,
            strategy_actor=strategy_actor,
            execution_actor=execution_actor,
            review_actor=review_actor,
            estimation_actor=estimation_actor,
            invariant_actor=invariant_actor,
            arguments=arguments or [],
            invariants=invariants or [],
            automation_profile=automation_profile,
            reusable=reusable,
            read_only=read_only,
            state=ActionState.AVAILABLE,
            created_by=created_by,
            tags=tags or [],
        )

        # Persist or store in-memory
        if self._persisted and self.unit_of_work is not None:
            with self.unit_of_work.transaction() as ctx:
                self._persist_action_create(action, ctx)
        self._actions[action_name_str] = action

        self._logger.info(
            "Action created",
            action_name=action_name_str,
            name=str(namespaced_name),
        )

        return action

    def get_action(self, action_name: str) -> Action:
        """Get an action by namespaced name.

        Checks in-memory cache first, then falls back to the persistence
        layer if a UnitOfWork is available.  The name is normalised through
        ``NamespacedName.parse`` so callers do not need to pre-lowercase.

        Args:
            action_name: The namespaced name (e.g., 'local/code-coverage')

        Returns:
            The Action

        Raises:
            NotFoundError: If action not found
        """
        # Normalise so lookups are case-insensitive, consistent with
        # create_action which stores keys via NamespacedName.parse().
        normalised = str(NamespacedName.parse(action_name))

        action = self._actions.get(normalised)
        if action is not None:
            return action
        # Try persistence layer
        if self._persisted and self.unit_of_work is not None:
            with self.unit_of_work.transaction() as ctx:
                persisted = ctx.actions.get_by_name(normalised)
            if persisted is not None:
                self._actions[normalised] = persisted
                return persisted
        raise NotFoundError(
            resource_type="action",
            resource_id=action_name,
        )

    def get_action_by_name(self, name: str) -> Action:
        """Get an action by namespaced name string.

        Delegates to ``get_action`` which normalises the name through
        ``NamespacedName.parse``.  Retained for backwards compatibility
        with CLI callers that used the old ID-based lookup alongside a
        separate name-based lookup.

        Args:
            name: The namespaced name (e.g., 'local/code-coverage')

        Returns:
            The Action

        Raises:
            NotFoundError: If action not found
        """
        return self.get_action(name)

    def list_actions(
        self,
        namespace: str | None = None,
        state: ActionState | None = None,
    ) -> list[Action]:
        """List actions with optional filtering.

        When persistence is enabled, results are queried from the database
        so that actions created by previous CLI invocations are visible.
        Falls back to the in-memory cache on DB errors or when no UoW is
        wired.

        Args:
            namespace: Filter by namespace
            state: Filter by state

        Returns:
            List of matching Actions
        """
        # Query the database when persistence is enabled so that actions
        # created by previous CLI invocations are visible.
        if self._persisted and self.unit_of_work is not None:
            try:
                with self.unit_of_work.transaction() as ctx:
                    if namespace:
                        state_str = state.value if state else None
                        actions = ctx.actions.get_by_namespace(
                            namespace, state=state_str
                        )
                    else:
                        actions = ctx.actions.list_all()
                # Refresh the in-memory cache with persisted actions.
                for a in actions:
                    key = str(a.namespaced_name)
                    if key not in self._actions:
                        self._actions[key] = a
                # Apply state filter when list_all was used (no namespace).
                if state and not namespace:
                    actions = [a for a in actions if a.state == state]
                return actions
            except DatabaseError:
                logger.debug("DB action list failed, falling back to in-memory cache")

        # In-memory fallback (no persistence or DB error).
        actions = list(self._actions.values())

        if namespace:
            actions = [a for a in actions if a.namespaced_name.namespace == namespace]

        if state:
            actions = [a for a in actions if a.state == state]

        return actions

    def make_action_available(self, action_name: str) -> Action:
        """No-op: actions now default to available state on creation.

        Kept for backwards compatibility.

        Args:
            action_name: The namespaced name (e.g., 'local/code-coverage')

        Returns:
            The Action (unchanged)

        Raises:
            NotFoundError: If action not found
        """
        action = self.get_action(action_name)

        self._logger.info(
            "make_action_available called (no-op, already available)",
            action_name=action_name,
        )

        return action

    def archive_action(self, action_name: str) -> Action:
        """Archive an action.

        Args:
            action_name: The namespaced name (e.g., 'local/code-coverage')

        Returns:
            The updated Action

        Raises:
            NotFoundError: If action not found
        """
        action = self.get_action(action_name)
        action.state = ActionState.ARCHIVED
        action.updated_at = datetime.now()

        # Persist state change
        if self._persisted and self.unit_of_work is not None:
            with self.unit_of_work.transaction() as ctx:
                self._persist_action_update(action, ctx)

        self._logger.info("Action archived", action_name=action_name)

        return action

    # Plan Management

    def use_action(
        self,
        action_name: str,
        project_links: list[ProjectLink] | None = None,
        arguments: dict[str, Any] | None = None,
        created_by: str | None = None,
        invariants: list[PlanInvariant] | None = None,
        automation_profile: str | None = None,
    ) -> Plan:
        """Use an action on projects to create a plan in Strategize phase.

        This is the 'use' command.  Plans start directly in Strategize
        (the Action phase is modeled separately in the Action domain model).

        Args:
            action_name: The namespaced name of the action
                to use (e.g., 'local/code-coverage')
            project_links: Projects to apply the action to (with alias/read_only)
            arguments: Argument values for the action
            created_by: User/session creating the plan
            invariants: Additional plan-level invariants
            automation_profile: Optional plan-level override profile name

        Returns:
            The created Plan in Strategize phase

        Raises:
            NotFoundError: If action not found
            ActionNotAvailableError: If action is not in available state
            ValidationError: If required arguments are missing or invalid
        """
        links = project_links or []

        self._logger.info(
            "Using action",
            action_name=action_name,
            projects=[link.project_name for link in links],
        )

        action = self.get_action(action_name)

        # Verify action is available
        if action.state != ActionState.AVAILABLE:
            raise ActionNotAvailableError(action_name, action.state)

        # Validate arguments
        args = arguments or {}
        validation_errors = action.validate_arguments(args)
        if validation_errors:
            joined_errors = "; ".join(validation_errors)
            raise ValidationError(
                f"Invalid arguments for action {action_name}: {joined_errors}"
            )

        # Build merged invariants: plan > action > (project/global added later)
        merged_invariants: list[PlanInvariant] = list(invariants or [])
        for inv_text in action.invariants:
            merged_invariants.append(
                PlanInvariant(text=inv_text, source=InvariantSource.ACTION)
            )

        # Create plan
        plan_id = self._generate_ulid()
        action_full_name = str(action.namespaced_name)
        plan_name = f"{action.namespaced_name.name}-{plan_id[:8]}"
        resolved_profile = self._resolve_plan_profile_ref(
            plan_profile=automation_profile,
            action_profile=action.automation_profile,
            project_links=links,
        )

        # Resolve estimation_actor via 4-level fallback chain:
        #   CLI --estimation-actor override (applied post-creation by CLI)
        #   > action YAML estimation_actor
        #   > project config (not applicable here; no project-scoped key)
        #   > actor.default.estimation global config
        resolved_estimation_actor: str | None = action.estimation_actor
        if not resolved_estimation_actor and self._config_service is not None:
            try:
                cfg_result = self._config_service.resolve("actor.default.estimation")
                if isinstance(cfg_result.value, str) and cfg_result.value.strip():
                    resolved_estimation_actor = cfg_result.value.strip()
            except Exception:
                pass  # Config lookup failure is non-fatal

        plan = Plan(
            identity=PlanIdentity(plan_id=plan_id),
            namespaced_name=NamespacedName(
                server=action.namespaced_name.server,
                namespace=action.namespaced_name.namespace,
                name=plan_name,
            ),
            action_name=action_full_name,
            description=action.long_description
            or action.description
            or str(action.namespaced_name),
            definition_of_done=action.definition_of_done,
            phase=PlanPhase.STRATEGIZE,
            processing_state=ProcessingState.QUEUED,
            project_links=links,
            strategy_actor=action.strategy_actor,
            execution_actor=action.execution_actor,
            review_actor=action.review_actor,
            apply_actor=getattr(action, "apply_actor", None),
            estimation_actor=resolved_estimation_actor,
            invariant_actor=action.invariant_actor,
            invariants=merged_invariants,
            arguments=args,
            arguments_order=list(args.keys()),
            timestamps=PlanTimestamps(
                created_at=datetime.now(),
                updated_at=datetime.now(),
            ),
            created_by=created_by,
            tags=action.tags.copy(),
            reusable=action.reusable,
            read_only=action.read_only,
            automation_profile=resolved_profile,
        )

        # Persist or store in-memory
        if self._persisted and self.unit_of_work is not None:
            with self.unit_of_work.transaction() as ctx:
                self._persist_plan_create(plan, ctx)
        self._plans[plan_id] = plan

        # If action is not reusable, archive it
        if not action.reusable:
            self.archive_action(action_name)

        self._logger.info(
            "Plan created from action",
            plan_id=plan_id,
            action_name=action_full_name,
            phase=plan.phase.value,
        )
        if self.event_bus is not None:
            try:
                self.event_bus.emit(
                    DomainEvent(
                        event_type=EventType.PLAN_CREATED,
                        plan_id=plan_id,
                        details={
                            "action_name": action_full_name,
                            "phase": plan.phase.value,
                        },
                    )
                )
            except Exception:
                self._logger.warning(
                    "event_bus_emit_failed",
                    event_type="PLAN_CREATED",
                    plan_id=plan_id,
                    exc_info=True,
                )

        return plan

    def _resolve_plan_profile_ref(
        self,
        *,
        plan_profile: str | None,
        action_profile: str | None,
        project_links: list[ProjectLink],
    ) -> AutomationProfileRef:
        """Resolve and record automation profile precedence for a new plan.

        Precedence chain: plan > action > project > global.
        """
        normalized_plan_profile = (
            plan_profile.strip() if isinstance(plan_profile, str) else None
        )
        normalized_action_profile = (
            action_profile.strip() if isinstance(action_profile, str) else None
        )
        project_name = project_links[0].project_name if project_links else None

        project_profile: str | None = None
        raw_settings_profile = getattr(self.settings, "default_automation_profile", "")
        settings_fallback = (
            raw_settings_profile.strip()
            if isinstance(raw_settings_profile, str)
            else ""
        )
        if not settings_fallback or settings_fallback not in BUILTIN_PROFILES:
            settings_fallback = "manual"
        global_profile: str = settings_fallback
        if self._config_service is not None:
            try:
                global_resolved = self._config_service.resolve(
                    "core.automation-profile"
                )
                if isinstance(global_resolved.value, str):
                    resolved_global_value = global_resolved.value.strip()
                    if resolved_global_value:
                        global_profile = resolved_global_value

                if project_name:
                    project_resolved = self._config_service.resolve(
                        "core.automation-profile",
                        project_name=project_name,
                    )
                    if project_resolved.source is ConfigLevel.PROJECT and isinstance(
                        project_resolved.value, str
                    ):
                        candidate = project_resolved.value.strip()
                        if candidate:
                            project_profile = candidate
            except (KeyError, ValueError, OSError) as exc:
                self._logger.warning(
                    "Config resolution failed for automation profile, "
                    "falling back to settings default",
                    error=str(exc),
                    error_type=type(exc).__name__,
                    settings_fallback=settings_fallback,
                )
                global_profile = settings_fallback

        chosen_profile = global_profile
        provenance = AutomationProfileProvenance.GLOBAL
        if project_profile:
            chosen_profile = project_profile
            provenance = AutomationProfileProvenance.PROJECT
        if normalized_action_profile:
            chosen_profile = normalized_action_profile
            provenance = AutomationProfileProvenance.ACTION
        if normalized_plan_profile:
            chosen_profile = normalized_plan_profile
            provenance = AutomationProfileProvenance.PLAN

        if chosen_profile not in BUILTIN_PROFILES:
            raise ValidationError(
                f"Unknown automation profile '{chosen_profile}'. "
                f"Available built-ins: {', '.join(sorted(BUILTIN_PROFILES))}"
            )

        self._logger.info(
            "Resolved automation profile for plan",
            resolved_profile=chosen_profile,
            provenance=provenance.value,
            plan_profile=normalized_plan_profile,
            action_profile=normalized_action_profile,
            project_profile=project_profile,
            global_profile=global_profile,
            project_name=project_name,
        )

        return AutomationProfileRef(
            profile_name=chosen_profile,
            provenance=provenance,
        )

    def get_plan(self, plan_id: str) -> Plan:
        """Get a plan by ID.

        Checks in-memory cache first, then falls back to the persistence
        layer if a UnitOfWork is available.

        Args:
            plan_id: The plan ULID

        Returns:
            The Plan

        Raises:
            NotFoundError: If plan not found
        """
        plan = self._plans.get(plan_id)
        if plan is not None:
            return plan
        # Try persistence layer
        if self._persisted and self.unit_of_work is not None:
            with self.unit_of_work.transaction() as ctx:
                persisted = ctx.lifecycle_plans.get(plan_id)
            if persisted is not None:
                self._plans[plan_id] = persisted
                return persisted
        raise NotFoundError(
            resource_type="plan",
            resource_id=plan_id,
        )

    def list_plans(
        self,
        namespace: str | None = None,
        phase: PlanPhase | None = None,
        project_name: str | None = None,
    ) -> list[Plan]:
        """List plans with optional filtering.

        Queries the database when persistence is enabled, falling back
        to the in-memory cache otherwise.

        Args:
            namespace: Filter by namespace
            phase: Filter by phase
            project_name: Filter by project name (matches project_links)

        Returns:
            List of matching Plans
        """
        # Query the database when persistence is enabled so that plans
        # created by previous CLI invocations are visible.
        if self._persisted and self.unit_of_work is not None:
            try:
                with self.unit_of_work.transaction() as ctx:
                    plans = ctx.lifecycle_plans.list_all()
                # Refresh the in-memory cache with persisted plans.
                for p in plans:
                    pid = str(p.identity.plan_id)
                    if pid not in self._plans:
                        self._plans[pid] = p
            except DatabaseError:
                logger.debug("DB list_all failed, falling back to in-memory cache")
                plans = list(self._plans.values())
        else:
            plans = list(self._plans.values())

        if namespace:
            plans = [p for p in plans if p.namespaced_name.namespace == namespace]

        if phase:
            plans = [p for p in plans if p.phase == phase]

        if project_name:
            plans = [
                p
                for p in plans
                if any(link.project_name == project_name for link in p.project_links)
            ]

        return plans

    def start_strategize(self, plan_id: str) -> Plan:
        """Start the Strategize phase processing.

        Runs the 7 pre-flight guardrail checks before transitioning
        the plan from QUEUED to PROCESSING state.  If any check fails a
        ``PreflightRejection`` is raised and the plan stays QUEUED.

        Args:
            plan_id: The plan ULID

        Returns:
            The updated Plan

        Raises:
            NotFoundError: If plan not found
            PlanNotReadyError: If plan is not in correct state
            PreflightRejection: If any pre-flight guardrail fails
        """
        plan = self.get_plan(plan_id)

        if plan.phase != PlanPhase.STRATEGIZE:
            raise PlanError(
                f"Plan {plan_id} is not in Strategize phase "
                f"(current: {plan.phase.value})"
            )

        if plan.state != ProcessingState.QUEUED:
            raise PlanNotReadyError(
                plan_id, plan.phase, plan.state or ProcessingState.QUEUED
            )

        # -- Pre-flight guardrail checks ----------------------------------
        # Ensure the plan's action is loaded from the persistence layer
        # into the in-memory cache so the action_registry is populated
        # even when this service instance was created by a fresh CLI
        # process that did not register the action itself (e.g. ``plan
        # execute`` in a separate invocation from ``plan use``).
        if (
            plan.action_name
            and plan.action_name not in self._actions
            and self._persisted
            and self.unit_of_work is not None
        ):
            with suppress(NotFoundError):
                self.get_action(plan.action_name)

        action_registry: dict[str, object] = {
            name: act for name, act in self._actions.items()
        }

        # Populate all 4 actor roles.  Optional roles (estimation,
        # invariant_reconciliation) use a placeholder when not explicitly
        # set, since the spec treats them as optional on the action.
        def _actor_registry_value(actor_name: str | None, default_token: str) -> object:
            if not actor_name:
                return default_token
            resolved = self._resolve_actor_registry_entry(actor_name)
            return resolved if resolved is not None else actor_name

        actor_registry: dict[str, object] = {
            "strategy": _actor_registry_value(plan.strategy_actor, "__unset__"),
            "execution": _actor_registry_value(plan.execution_actor, "__unset__"),
            "estimation": _actor_registry_value(plan.estimation_actor, "__optional__"),
            "invariant_reconciliation": _actor_registry_value(
                plan.invariant_actor, "__optional__"
            ),
        }

        self.preflight_guardrail.run_all_checks(
            action_name=plan.action_name,
            action_registry=action_registry,
            actor_registry=actor_registry,
            actor_resolver=self._resolve_actor_registry_entry,
            automation_profile=self._resolve_profile_for_plan(plan),
        )
        # -- End pre-flight -----------------------------------------------

        # -- Invariant Reconciliation (blocks on failure) -----------------
        # Spec: reconciliation runs at the start of Strategize to
        # verify plan invariants hold before processing begins.
        self._run_invariant_reconciliation(plan)
        # -- End reconciliation -------------------------------------------

        plan.processing_state = ProcessingState.PROCESSING
        plan.timestamps.strategize_started_at = datetime.now()
        plan.timestamps.updated_at = datetime.now()

        self._commit_plan(plan)
        self._logger.info("Strategize started", plan_id=plan_id)

        self._try_record_decision(
            plan_id=plan_id,
            decision_type="strategy_choice",
            question="Which strategy should the plan follow?",
            chosen_option=f"Begin strategize phase for plan {plan_id}",
        )

        return plan

    def complete_strategize(self, plan_id: str) -> Plan:
        """Complete the Strategize phase.

        Transitions the plan to COMPLETE state, ready for execute command.

        Args:
            plan_id: The plan ULID

        Returns:
            The updated Plan

        Raises:
            NotFoundError: If plan not found
            PlanError: If plan is not processing
        """
        plan = self.get_plan(plan_id)

        if plan.phase != PlanPhase.STRATEGIZE:
            raise PlanError(
                f"Plan {plan_id} is not in Strategize phase "
                f"(current: {plan.phase.value})"
            )

        if plan.state != ProcessingState.PROCESSING:
            raise PlanError(f"Plan {plan_id} is not processing (current: {plan.state})")

        plan.processing_state = ProcessingState.COMPLETE
        plan.timestamps.strategize_completed_at = datetime.now()
        plan.timestamps.updated_at = datetime.now()

        self._commit_plan(plan)
        self._logger.info("Strategize completed", plan_id=plan_id)
        if self.event_bus is not None:
            try:
                self.event_bus.emit(
                    DomainEvent(
                        event_type=EventType.PLAN_STATE_CHANGED,
                        plan_id=plan_id,
                        details={
                            "phase": plan.phase.value,
                            "processing_state": plan.processing_state.value
                            if plan.processing_state
                            else None,
                        },
                    )
                )
            except Exception:
                self._logger.warning(
                    "event_bus_emit_failed",
                    event_type="PLAN_STATE_CHANGED",
                    plan_id=plan_id,
                    exc_info=True,
                )

        # Auto-progress if automation level permits
        return self.auto_progress(plan_id)

    def fail_strategize(self, plan_id: str, error_message: str) -> Plan:
        """Mark Strategize phase as failed.

        Args:
            plan_id: The plan ULID
            error_message: Error description

        Returns:
            The updated Plan
        """
        plan = self.get_plan(plan_id)
        plan.processing_state = ProcessingState.ERRORED
        plan.error_message = error_message
        plan.timestamps.updated_at = datetime.now()

        self._commit_plan(plan)
        self._logger.error("Strategize failed", plan_id=plan_id, error=error_message)
        if self.event_bus is not None:
            try:
                self.event_bus.emit(
                    DomainEvent(
                        event_type=EventType.PLAN_ERRORED,
                        plan_id=plan_id,
                        details={
                            "phase": plan.phase.value,
                            "error_message": error_message,
                        },
                    )
                )
            except Exception:
                self._logger.warning(
                    "event_bus_emit_failed",
                    event_type="PLAN_ERRORED",
                    plan_id=plan_id,
                    exc_info=True,
                )

        return plan

    def execute_plan(self, plan_id: str) -> Plan:
        """Transition plan from Strategize to Execute phase.

        This is the 'execute' command.

        Args:
            plan_id: The plan ULID

        Returns:
            The updated Plan in Execute phase

        Raises:
            NotFoundError: If plan not found
            InvalidPhaseTransitionError: If transition is not valid
            PlanNotReadyError: If plan is not ready for transition
        """
        plan = self.get_plan(plan_id)

        # Validate transition
        if not can_transition(plan.phase, PlanPhase.EXECUTE):
            raise InvalidPhaseTransitionError(plan.phase, PlanPhase.EXECUTE)

        # Must be in COMPLETE state to transition
        if plan.state != ProcessingState.COMPLETE:
            raise PlanNotReadyError(
                plan_id, plan.phase, plan.state or ProcessingState.QUEUED
            )

        # Run estimation actor if configured (informational only)
        self._run_estimation(plan)

        # Layer 4: Consult Error Pattern Database for preventive guidance
        self._consult_error_patterns(plan)

        # Invariant Reconciliation: verify invariants before Execute
        self._run_invariant_reconciliation(plan)

        # Transition to Execute phase — set processing_state first so that
        # the phase-state validator sees QUEUED (valid in any phase) when
        # the phase assignment triggers re-validation.
        plan.processing_state = ProcessingState.QUEUED
        plan.phase = PlanPhase.EXECUTE
        plan.timestamps.updated_at = datetime.now()

        self._commit_plan(plan)
        self._logger.info(
            "Plan transitioned to Execute",
            plan_id=plan_id,
            phase=plan.phase.value,
        )
        if self.event_bus is not None:
            try:
                self.event_bus.emit(
                    DomainEvent(
                        event_type=EventType.PLAN_PHASE_CHANGED,
                        plan_id=plan_id,
                        details={
                            "phase": plan.phase.value,
                            "processing_state": plan.processing_state.value
                            if plan.processing_state
                            else None,
                        },
                    )
                )
            except Exception:
                self._logger.warning(
                    "event_bus_emit_failed",
                    event_type="PLAN_PHASE_CHANGED",
                    plan_id=plan_id,
                    exc_info=True,
                )

        # Enqueue async job when async execution is enabled
        self._maybe_enqueue_async_job(plan_id, "execute")

        return plan

    def start_execute(self, plan_id: str) -> Plan:
        """Start the Execute phase processing."""
        plan = self.get_plan(plan_id)

        if plan.phase != PlanPhase.EXECUTE:
            raise PlanError(
                f"Plan {plan_id} is not in Execute phase (current: {plan.phase.value})"
            )

        if plan.state != ProcessingState.QUEUED:
            raise PlanNotReadyError(
                plan_id, plan.phase, plan.state or ProcessingState.QUEUED
            )

        plan.processing_state = ProcessingState.PROCESSING
        plan.timestamps.execute_started_at = datetime.now()
        plan.timestamps.updated_at = datetime.now()

        self._commit_plan(plan)
        self._logger.info("Execute started", plan_id=plan_id)

        self._try_record_decision(
            plan_id=plan_id,
            decision_type="implementation_choice",
            question="How should the plan be executed?",
            chosen_option=f"Begin execute phase for plan {plan_id}",
        )

        return plan

    def complete_execute(self, plan_id: str) -> Plan:
        """Complete the Execute phase."""
        plan = self.get_plan(plan_id)

        if plan.phase != PlanPhase.EXECUTE:
            raise PlanError(
                f"Plan {plan_id} is not in Execute phase (current: {plan.phase.value})"
            )

        if plan.state != ProcessingState.PROCESSING:
            raise PlanError(f"Plan {plan_id} is not processing (current: {plan.state})")

        plan.processing_state = ProcessingState.COMPLETE
        plan.timestamps.execute_completed_at = datetime.now()
        plan.timestamps.updated_at = datetime.now()

        self._commit_plan(plan)
        self._logger.info("Execute completed", plan_id=plan_id)
        if self.event_bus is not None:
            try:
                self.event_bus.emit(
                    DomainEvent(
                        event_type=EventType.PLAN_STATE_CHANGED,
                        plan_id=plan_id,
                        details={
                            "phase": plan.phase.value,
                            "processing_state": plan.processing_state.value
                            if plan.processing_state
                            else None,
                        },
                    )
                )
            except Exception:
                self._logger.warning(
                    "event_bus_emit_failed",
                    event_type="PLAN_STATE_CHANGED",
                    plan_id=plan_id,
                    exc_info=True,
                )

        # Auto-progress if automation level permits
        return self.auto_progress(plan_id)

    def fail_execute(self, plan_id: str, error_message: str) -> Plan:
        """Mark Execute phase as failed."""
        plan = self.get_plan(plan_id)
        plan.processing_state = ProcessingState.ERRORED
        plan.error_message = error_message
        plan.timestamps.updated_at = datetime.now()

        self._commit_plan(plan)
        self._logger.error("Execute failed", plan_id=plan_id, error=error_message)
        if self.event_bus is not None:
            try:
                self.event_bus.emit(
                    DomainEvent(
                        event_type=EventType.PLAN_ERRORED,
                        plan_id=plan_id,
                        details={
                            "phase": plan.phase.value,
                            "error_message": error_message,
                        },
                    )
                )
            except Exception:
                self._logger.warning(
                    "event_bus_emit_failed",
                    event_type="PLAN_ERRORED",
                    plan_id=plan_id,
                    exc_info=True,
                )

        # R7-F2 fix: clean up containers on execute failure (terminal state).
        self._cleanup_devcontainers(plan_id)

        return plan

    def apply_plan(self, plan_id: str) -> Plan:
        """Transition plan from Execute to Apply phase.

        This is the 'apply' command.

        Args:
            plan_id: The plan ULID

        Returns:
            The updated Plan in Apply phase

        Raises:
            NotFoundError: If plan not found
            InvalidPhaseTransitionError: If transition is not valid
            PlanNotReadyError: If plan is not ready for transition
        """
        plan = self.get_plan(plan_id)

        # Validate transition
        if not can_transition(plan.phase, PlanPhase.APPLY):
            raise InvalidPhaseTransitionError(plan.phase, PlanPhase.APPLY)

        # Must be in COMPLETE state to transition
        if plan.state != ProcessingState.COMPLETE:
            raise PlanNotReadyError(
                plan_id, plan.phase, plan.state or ProcessingState.QUEUED
            )

        # Invariant Reconciliation: verify invariants before Apply
        self._run_invariant_reconciliation(plan)

        # Transition to Apply phase — set processing_state first so that
        # the phase-state validator sees QUEUED (valid in any phase) when
        # the phase assignment triggers re-validation.
        plan.processing_state = ProcessingState.QUEUED
        plan.phase = PlanPhase.APPLY
        plan.timestamps.apply_started_at = datetime.now()
        plan.timestamps.updated_at = datetime.now()

        self._commit_plan(plan)
        self._logger.info(
            "Plan transitioned to Apply",
            plan_id=plan_id,
            phase=plan.phase.value,
        )

        # Enqueue async job when async execution is enabled
        self._maybe_enqueue_async_job(plan_id, "apply")

        return plan

    def start_apply(self, plan_id: str) -> Plan:
        """Start the Apply phase processing."""
        plan = self.get_plan(plan_id)

        if plan.phase != PlanPhase.APPLY:
            raise PlanError(
                f"Plan {plan_id} is not in Apply phase (current: {plan.phase.value})"
            )

        if plan.state != ProcessingState.QUEUED:
            raise PlanNotReadyError(
                plan_id, plan.phase, plan.state or ProcessingState.QUEUED
            )

        plan.processing_state = ProcessingState.PROCESSING
        plan.timestamps.updated_at = datetime.now()

        self._commit_plan(plan)
        self._logger.info("Apply started", plan_id=plan_id)

        return plan

    def complete_apply(
        self,
        plan_id: str,
        *,
        files_changed: int = 0,
        lines_added: int = 0,
        lines_removed: int = 0,
        resources_modified: int = 0,
        apply_duration_seconds: float = 0.0,
    ) -> Plan:
        """Complete the Apply phase — terminal success.

        Sets ``processing_state`` to ``APPLIED`` (the terminal success
        state within the Apply phase).  The phase remains ``APPLY``.

        The optional changeset statistics parameters enrich the emitted
        ``PLAN_APPLIED`` domain event with SEC7 audit-logging context
        (see docs/specification.md §Audit Logging).

        Args:
            plan_id: The plan ULID.
            files_changed: Number of files changed during apply.
            lines_added: Total lines added across all changed files.
            lines_removed: Total lines removed across all changed files.
            resources_modified: Number of distinct resources modified.
            apply_duration_seconds: Wall-clock duration of the apply
                operation in seconds.

        Returns:
            The updated Plan with processing_state=APPLIED
        """
        plan = self.get_plan(plan_id)

        if plan.phase != PlanPhase.APPLY:
            raise PlanError(
                f"Plan {plan_id} is not in Apply phase (current: {plan.phase.value})"
            )

        if plan.state != ProcessingState.PROCESSING:
            raise PlanError(f"Plan {plan_id} is not processing (current: {plan.state})")

        # Terminal success: phase stays APPLY, state becomes APPLIED
        plan.processing_state = ProcessingState.APPLIED
        plan.timestamps.applied_at = datetime.now()
        plan.timestamps.updated_at = datetime.now()

        self._commit_plan(plan)
        self._logger.info(
            "Plan applied successfully",
            plan_id=plan_id,
            phase=plan.phase.value,
        )
        if self.event_bus is not None:
            try:
                project_names = [
                    link.project_name for link in (plan.project_links or [])
                ]
                # BUG-2: The top-level ``project_name`` column in the audit_log
                # table stores only the first project for multi-project plans.
                # All project names are captured in ``details["project_names"]``
                # for completeness.  This is a known schema limitation.
                self.event_bus.emit(
                    DomainEvent(
                        event_type=EventType.PLAN_APPLIED,
                        plan_id=plan_id,
                        actor_name=plan.created_by,
                        project_name=project_names[0] if project_names else None,
                        details={
                            "action_name": plan.action_name,
                            "phase": plan.phase.value,
                            "project_names": project_names,
                            # SEC7 changeset statistics (issue #716)
                            "files_changed": files_changed,
                            "lines_added": lines_added,
                            "lines_removed": lines_removed,
                            "resources_modified": resources_modified,
                            "apply_duration_seconds": apply_duration_seconds,
                        },
                    )
                )
            except Exception:
                self._logger.warning(
                    "event_bus_emit_failed",
                    event_type="PLAN_APPLIED",
                    plan_id=plan_id,
                    exc_info=True,
                )

        # F1-r6 fix: wire container cleanup to plan completion hook.
        self._cleanup_devcontainers(plan_id)

        return plan

    def constrain_apply(self, plan_id: str, reason: str) -> Plan:
        """Mark the Apply phase as constrained.

        The plan cannot complete within the current strategy constraints.
        It may revert to Strategize for re-planning.

        Args:
            plan_id: The plan ULID
            reason: Why the apply is constrained

        Returns:
            The updated Plan with processing_state=CONSTRAINED
        """
        plan = self.get_plan(plan_id)

        if plan.phase != PlanPhase.APPLY:
            raise PlanError(
                f"Plan {plan_id} is not in Apply phase (current: {plan.phase.value})"
            )

        plan.processing_state = ProcessingState.CONSTRAINED
        plan.error_message = reason
        plan.timestamps.updated_at = datetime.now()

        self._commit_plan(plan)
        self._logger.info(
            "Plan apply constrained",
            plan_id=plan_id,
            reason=reason,
        )

        return plan

    def fail_apply(self, plan_id: str, error_message: str) -> Plan:
        """Mark Apply phase as failed."""
        plan = self.get_plan(plan_id)
        plan.processing_state = ProcessingState.ERRORED
        plan.error_message = error_message
        plan.timestamps.updated_at = datetime.now()

        self._commit_plan(plan)
        self._logger.error("Apply failed", plan_id=plan_id, error=error_message)
        if self.event_bus is not None:
            try:
                self.event_bus.emit(
                    DomainEvent(
                        event_type=EventType.PLAN_ERRORED,
                        plan_id=plan_id,
                        details={
                            "phase": plan.phase.value,
                            "error_message": error_message,
                        },
                    )
                )
            except Exception:
                self._logger.warning(
                    "event_bus_emit_failed",
                    event_type="PLAN_ERRORED",
                    plan_id=plan_id,
                    exc_info=True,
                )

        # R7-F2 fix: clean up containers on apply failure (terminal state).
        self._cleanup_devcontainers(plan_id)

        return plan

    def prompt_plan(self, plan_id: str, guidance: str) -> dict[str, Any]:
        """Inject user guidance into an executing plan.

        The prompt command is only valid while a plan is in the Execute
        phase and actively recoverable (queued, processing, or errored).
        Guidance is recorded as a ``user_intervention`` decision and the
        plan is moved (or kept) in ``processing`` state so execution can
        continue.

        Args:
            plan_id: Plan ULID.
            guidance: Guidance text from the user.

        Returns:
            Spec-aligned prompt payload used by CLI/A2A responses.

        Raises:
            ValidationError: If guidance is blank.
            PlanError: If the plan is not in an active Execute state.
        """
        if not guidance.strip():
            raise ValidationError("guidance must not be blank")

        plan = self.get_plan(plan_id)

        allowed_states = {
            ProcessingState.QUEUED,
            ProcessingState.PROCESSING,
            ProcessingState.ERRORED,
        }
        if (
            plan.phase != PlanPhase.EXECUTE
            or plan.processing_state not in allowed_states
        ):
            raise PlanError(
                f"Plan {plan_id} is not in active execute phase "
                f"(current: {plan.phase.value}/{plan.processing_state.value})"
            )

        previous_state = plan.processing_state
        if plan.processing_state != ProcessingState.PROCESSING:
            plan.processing_state = ProcessingState.PROCESSING
            # Guidance injection is a recovery action, so stale error text
            # should not persist as the active plan error once resumed.
            if previous_state == ProcessingState.ERRORED:
                plan.error_message = None
            plan.timestamps.updated_at = datetime.now()
            self._commit_plan(plan)

        state_transition = f"{previous_state.value} -> {plan.processing_state.value}"

        parent_decision_id: str | None = None
        decision_id = str(ULID())
        if self.decision_service is not None:
            try:
                decisions = self.decision_service.list_decisions(plan_id)
                if decisions:
                    parent = max(decisions, key=lambda d: d.sequence_number)
                    parent_decision_id = parent.decision_id

                decision = self.decision_service.record_decision(
                    plan_id=plan_id,
                    decision_type=DecisionType.USER_INTERVENTION,
                    question="What additional guidance should be applied?",
                    chosen_option=guidance,
                    parent_decision_id=parent_decision_id,
                    rationale="User guidance injected via `agents plan prompt`.",
                    plan_phase=PlanPhase.EXECUTE,
                )
                decision_id = decision.decision_id
                parent_decision_id = decision.parent_decision_id
            except Exception:
                self._logger.warning(
                    "plan_prompt_decision_record_failed",
                    plan_id=plan_id,
                    exc_info=True,
                )

        return {
            "guidance_added": {
                "plan": plan_id,
                "guidance": guidance,
                "scope": "next execution step",
                "phase": "execute",
                "state_transition": state_transition,
            },
            "decision_created": {
                "type": "user_intervention",
                "id": decision_id,
                "parent": parent_decision_id,
            },
            "queue": {
                "pending": 1,
                "applied": 0,
            },
        }

    def cancel_plan(self, plan_id: str, reason: str | None = None) -> Plan:
        """Cancel a plan.

        Args:
            plan_id: The plan ULID
            reason: Optional cancellation reason

        Returns:
            The updated Plan

        Raises:
            NotFoundError: If plan not found
            PlanError: If plan is in terminal state
        """
        plan = self.get_plan(plan_id)

        if plan.is_terminal:
            raise PlanError(
                f"Plan {plan_id} is already in terminal state and cannot be cancelled"
            )

        # Capture progress and resource context *before* mutating the plan
        # so the event reflects the state at the moment of cancellation.
        cancelled_phase: str = str(plan.phase)
        cancelled_processing_state: str = str(plan.processing_state)
        last_completed_step: int = plan.last_completed_step
        sandbox_refs: list[str] = list(plan.sandbox_refs)
        changeset_id: str | None = plan.changeset_id
        subplan_count: int = len(plan.subplan_statuses)

        plan.processing_state = ProcessingState.CANCELLED
        plan.error_message = reason
        plan.timestamps.updated_at = datetime.now()

        self._commit_plan(plan)
        self._logger.info("Plan cancelled", plan_id=plan_id, reason=reason)
        if self.event_bus is not None:
            try:
                project_names = [
                    link.project_name for link in (plan.project_links or [])
                ]
                # Build enriched details: progress context + resource cleanup
                # context as required by issue #717 / SEC7 audit logging spec.
                event_details: dict[str, object] = {
                    "reason": reason or "",
                    "project_names": project_names,
                    # Progress context — where the plan was when cancelled
                    "cancelled_phase": cancelled_phase,
                    "cancelled_processing_state": cancelled_processing_state,
                    "last_completed_step": last_completed_step,
                    "subplan_count": subplan_count,
                    # Resource cleanup context — what needs / needed cleanup
                    "sandbox_refs": sandbox_refs,
                    "changeset_id": changeset_id,
                    "resources_pending_cleanup": len(sandbox_refs),
                }
                self.event_bus.emit(
                    DomainEvent(
                        event_type=EventType.PLAN_CANCELLED,
                        plan_id=plan_id,
                        actor_name=plan.created_by,
                        project_name=project_names[0] if project_names else None,
                        details=event_details,
                    )
                )
            except Exception:
                self._logger.warning(
                    "event_bus_emit_failed",
                    event_type="PLAN_CANCELLED",
                    plan_id=plan_id,
                    exc_info=True,
                )

        # F1-r6 fix: wire container cleanup to plan cancellation hook.
        self._cleanup_devcontainers(plan_id)

        return plan

    # -- Devcontainer cleanup helper (F1-r6) --------------------------

    def _cleanup_devcontainers(self, plan_id: str) -> None:
        """Best-effort stop of active devcontainers on plan completion.

        Called from terminal plan states (``complete_apply``,
        ``cancel_plan``) to ensure containers don't outlive the plan.
        Failures are logged but never propagated.
        """
        try:
            from cleveragents.application.services.cleanup_service import (
                CleanupService,
            )

            stopped = CleanupService.stop_active_devcontainers(
                session_id=plan_id,
            )
            if stopped:
                self._logger.info(
                    "plan.devcontainer_cleanup",
                    plan_id=plan_id,
                    stopped_count=len(stopped),
                )
        except Exception:
            self._logger.warning(
                "plan.devcontainer_cleanup_failed",
                plan_id=plan_id,
                exc_info=True,
            )

    # -- Profile-based auto-progress helpers -------------------------

    def _resolve_profile_for_plan(self, plan: Plan) -> AutomationProfile:
        """Resolve the effective AutomationProfile for a plan.

        Uses the plan's ``automation_profile`` reference.  Falls back
        to the ``"manual"`` built-in profile when no profile is set.

        Custom profile names that are not found in ``BUILTIN_PROFILES``
        raise a ``ValidationError`` rather than silently falling back to
        ``"manual"``.  This ensures misconfigured custom profiles are
        surfaced immediately instead of executing in an unintended mode.

        Args:
            plan: The plan to resolve the profile for.

        Returns:
            The resolved ``AutomationProfile``.

        Raises:
            ValidationError: If the plan's profile name is not a known
                built-in profile.
        """
        if plan.automation_profile is not None:
            profile_name = plan.automation_profile.profile_name
        else:
            profile_name = "manual"

        profile = BUILTIN_PROFILES.get(profile_name)
        if profile is None:
            raise ValidationError(
                f"Automation profile '{profile_name}' is not a known built-in profile. "
                f"Available profiles: {', '.join(sorted(BUILTIN_PROFILES))}. "
                "Custom profiles must be registered before use."
            )

        self._logger.debug(
            "resolved_automation_profile_for_plan",
            plan_id=plan.identity.plan_id,
            profile_name=profile_name,
        )
        return profile

    def should_auto_progress(self, plan: Plan) -> bool:
        """Check whether the plan should automatically advance.

        Uses AutomationProfile thresholds: a threshold of ``0.0``
        on the relevant phase means the transition is fully
        automatic. A threshold of ``1.0`` requires human approval.

        Task-type threshold fields used as phase-transition gates
        (per specification, Section "Automation Profiles"):

        * ``create_tool``    → Strategize → Execute
        * ``select_tool``    → Execute → Apply
        * ``access_network`` → auto-revert from Apply
        * ``delete_content`` → auto-revert from Execute (strategy revision)

        Returns True when:

        - Strategize/COMPLETE and ``create_tool < 1.0``
        - Execute/COMPLETE and ``select_tool < 1.0``

        Returns False otherwise.
        """
        if plan.is_terminal:
            return False

        profile = self._resolve_profile_for_plan(plan)

        # Strategize complete -> auto-execute?
        if (
            plan.phase == PlanPhase.STRATEGIZE
            and plan.processing_state == ProcessingState.COMPLETE
            and profile.create_tool < 1.0
        ):
            return True

        # Execute complete -> auto-apply?
        return bool(
            plan.phase == PlanPhase.EXECUTE
            and plan.processing_state == ProcessingState.COMPLETE
            and profile.select_tool < 1.0
        )

    def _complete_apply_if_queued(self, plan_id: str) -> Plan:
        """Drive a plan from Apply/QUEUED to terminal APPLIED.

        Apply is a metadata-only transition (no LLM call), so it is
        safe to complete synchronously.  If the plan is not in
        Apply/QUEUED, returns it unchanged.

        When async execution is enabled (server mode with a job store),
        the Apply phase is handled by the async worker; this method
        skips inline completion to avoid orphaning the queued async job.

        On failure the plan is moved to ``errored`` via
        :meth:`fail_apply` so it is never stuck in ``apply/processing``
        with no recovery path.

        Args:
            plan_id: The plan ULID.

        Returns:
            The (possibly updated) Plan.
        """
        plan = self.get_plan(plan_id)

        if plan.phase != PlanPhase.APPLY:
            return plan
        if plan.processing_state != ProcessingState.QUEUED:
            return plan

        # When async execution is enabled, an async job was already
        # enqueued by apply_plan(); let the async worker handle it.
        if self.settings.async_enabled and self._job_store is not None:
            self._logger.info(
                "Skipping inline Apply completion — async job enqueued",
                plan_id=plan_id,
            )
            return plan

        try:
            self.start_apply(plan_id)
            return self.complete_apply(plan_id)
        except Exception as exc:
            self._logger.error(
                "Apply completion failed; recording error state",
                plan_id=plan_id,
                error=str(exc),
            )
            return self.fail_apply(plan_id, str(exc))

    def auto_progress(self, plan_id: str) -> Plan:
        """If the plan's automation level permits, advance to the next phase.

        This is idempotent: calling it on a plan that should NOT auto-progress
        simply returns the plan unchanged.

        Returns:
            The (possibly updated) Plan.

        Raises:
            NotFoundError: If plan not found.
        """
        plan = self.get_plan(plan_id)

        if not self.should_auto_progress(plan):
            return plan

        if (
            plan.phase == PlanPhase.STRATEGIZE
            and plan.processing_state == ProcessingState.COMPLETE
        ):
            self._logger.info(
                "Auto-progressing plan from Strategize to Execute",
                plan_id=plan_id,
            )
            return self.execute_plan(plan_id)

        if (
            plan.phase == PlanPhase.EXECUTE
            and plan.processing_state == ProcessingState.COMPLETE
        ):
            self._logger.info(
                "Auto-progressing plan from Execute to Apply",
                plan_id=plan_id,
            )
            plan = self.apply_plan(plan_id)
            # Apply phase is a metadata transition (no LLM call), so
            # complete it immediately when auto-progressing.  This
            # ensures ``plan execute`` drives the plan to terminal
            # ``applied`` state when the automation profile permits
            # (spec §Automation Profiles: select_tool < 1.0).
            return self._complete_apply_if_queued(plan_id)

        return plan

    def try_auto_run(self, plan_id: str) -> Plan:
        """Drive the plan through its lifecycle automatically.

        When the plan's automation profile permits, this method advances
        the plan synchronously through Strategize → Execute → Apply:

        * ``decompose_task < 1.0`` → start + complete Strategize
        * ``create_tool < 1.0``    → start + complete Execute
        * ``select_tool < 1.0``    → start + complete Apply

        Each completion step calls :meth:`auto_progress` internally,
        which transitions the plan to the next phase when appropriate.

        If any threshold is ``1.0`` (human approval required), the plan
        stops at that phase boundary.  The method is idempotent: calling
        it on a plan already in a terminal state simply returns the plan.

        Args:
            plan_id: The plan ULID.

        Returns:
            The (possibly advanced) Plan.

        Raises:
            NotFoundError: If plan not found.
        """
        plan = self.get_plan(plan_id)

        if plan.is_terminal:
            return plan

        profile = self._resolve_profile_for_plan(plan)

        # --- Strategize phase: QUEUED → PROCESSING → COMPLETE -----------
        # (decompose_task threshold gates the Strategize phase start)
        if (
            plan.phase == PlanPhase.STRATEGIZE
            and plan.state == ProcessingState.QUEUED
            and profile.decompose_task < 1.0
        ):
            self._logger.info(
                "Auto-running strategize phase",
                plan_id=plan_id,
            )
            self.start_strategize(plan_id)
            # complete_strategize() calls auto_progress() which may
            # transition the plan to Execute/QUEUED.
            plan = self.complete_strategize(plan_id)
            plan = self.get_plan(plan_id)

        # --- Execute phase: QUEUED → PROCESSING → COMPLETE --------------
        # (create_tool threshold gates the Execute phase start)
        if (
            plan.phase == PlanPhase.EXECUTE
            and plan.state == ProcessingState.QUEUED
            and profile.create_tool < 1.0
        ):
            self._logger.info(
                "Auto-running execute phase",
                plan_id=plan_id,
            )
            self.start_execute(plan_id)
            # complete_execute() calls auto_progress() which may
            # transition the plan to Apply/QUEUED.
            plan = self.complete_execute(plan_id)
            plan = self.get_plan(plan_id)

        # --- Apply phase: QUEUED → PROCESSING → APPLIED (terminal) ------
        # (select_tool threshold gates the Apply phase start)
        if (
            plan.phase == PlanPhase.APPLY
            and plan.state == ProcessingState.QUEUED
            and profile.select_tool < 1.0
        ):
            self._logger.info(
                "Auto-running apply phase",
                plan_id=plan_id,
            )
            plan = self._complete_apply_if_queued(plan_id)

        return plan

    def pause_plan(self, plan_id: str) -> Plan:
        """Pause auto-progression by setting the automation profile to manual.

        Useful for review-before-apply: if a user wants to inspect changes
        before apply proceeds, they can pause the plan.

        Returns:
            The updated Plan.

        Raises:
            NotFoundError: If plan not found.
            PlanError: If plan is in a terminal state.
        """
        from cleveragents.domain.models.core.plan import (
            AutomationProfileProvenance,
            AutomationProfileRef,
        )

        plan = self.get_plan(plan_id)

        if plan.is_terminal:
            raise PlanError(f"Plan {plan_id} is in terminal state and cannot be paused")

        plan.automation_profile = AutomationProfileRef(
            profile_name="manual",
            provenance=AutomationProfileProvenance.PLAN,
        )
        plan.timestamps.updated_at = datetime.now()

        self._commit_plan(plan)
        self._logger.info(
            "Plan paused (automation profile set to manual)",
            plan_id=plan_id,
        )

        return plan

    def resume_plan(
        self,
        plan_id: str,
        automation_profile: str | None = None,
    ) -> Plan:
        """Resume auto-progression by setting a non-manual automation profile.

        If *automation_profile* is not provided, defaults to ``"auto"``
        so the plan will auto-progress but still pause before apply.

        After restoring the profile, calls ``auto_progress`` to immediately
        advance if the plan is ready.

        Returns:
            The (possibly advanced) Plan.

        Raises:
            NotFoundError: If plan not found.
            PlanError: If plan is in a terminal state.
        """
        from cleveragents.domain.models.core.plan import (
            AutomationProfileProvenance,
            AutomationProfileRef,
        )

        plan = self.get_plan(plan_id)

        if plan.is_terminal:
            raise PlanError(
                f"Plan {plan_id} is in terminal state and cannot be resumed"
            )

        profile_name = automation_profile or "auto"
        plan.automation_profile = AutomationProfileRef(
            profile_name=profile_name,
            provenance=AutomationProfileProvenance.PLAN,
        )
        plan.timestamps.updated_at = datetime.now()

        self._logger.info(
            "Plan resumed",
            plan_id=plan_id,
            automation_profile=profile_name,
        )

        # Attempt immediate auto-progression
        return self.auto_progress(plan_id)

    # -- Phase reversion methods -----------------------------------------

    def revert_plan(
        self,
        plan_id: str,
        to_phase: PlanPhase,
        reason: str | None = None,
    ) -> Plan:
        """Revert a plan to a previous phase (manual reversion).

        This is the manual reversion entry point, invoked by the CLI
        ``plan revert`` command.  It validates that the reversion is
        allowed, records a reversion decision, and resets the plan to
        the target phase.

        Args:
            plan_id: The plan ULID.
            to_phase: The target phase to revert to.
            reason: Human-readable reason for the reversion.

        Returns:
            The updated Plan in the reverted phase.

        Raises:
            NotFoundError: If plan not found.
            InvalidPhaseTransitionError: If reversion is not valid.
            PlanError: If the plan has exceeded the max reversion count.
        """
        plan = self.get_plan(plan_id)

        if plan.processing_state in (
            ProcessingState.APPLIED,
            ProcessingState.CANCELLED,
        ):
            raise PlanError(
                f"Plan {plan_id} is in terminal state "
                f"({plan.processing_state.value}) and cannot be reverted"
            )

        if plan.reversion_count >= plan.MAX_REVERSIONS:
            raise PlanError(
                f"Plan {plan_id} has exceeded the maximum reversion count "
                f"({plan.MAX_REVERSIONS}). Manual intervention required."
            )

        if not can_transition(plan.phase, to_phase):
            raise InvalidPhaseTransitionError(
                plan.phase,
                to_phase,
                message=(f"Cannot revert from {plan.phase.value} to {to_phase.value}"),
            )

        return self._perform_reversion(plan, to_phase, reason or "manual reversion")

    def try_auto_revert_from_apply(self, plan_id: str, reason: str) -> Plan:
        """Attempt automatic reversion from a constrained Apply phase.

        Called after ``constrain_apply`` when the automation profile
        permits automatic reversion.  The ``access_network`` task-type
        threshold controls this gate (``access_network < 1.0``).

        Args:
            plan_id: The plan ULID.
            reason: Why the apply was constrained.

        Returns:
            The (possibly reverted) Plan.
        """
        plan = self.get_plan(plan_id)

        if plan.phase != PlanPhase.APPLY:
            return plan

        if plan.processing_state != ProcessingState.CONSTRAINED:
            return plan

        profile = self._resolve_profile_for_plan(plan)
        if profile.access_network >= 1.0:
            self._logger.info(
                "Auto-reversion blocked by profile threshold",
                plan_id=plan_id,
                threshold_field="access_network",
                threshold=profile.access_network,
            )
            return plan

        if plan.reversion_count >= plan.MAX_REVERSIONS:
            self._logger.warning(
                "Auto-reversion blocked by loop guard",
                plan_id=plan_id,
                reversion_count=plan.reversion_count,
                max_reversions=plan.MAX_REVERSIONS,
            )
            return plan

        self._logger.info(
            "Auto-reverting plan from Apply to Strategize",
            plan_id=plan_id,
            reason=reason,
        )

        return self._perform_reversion(
            plan,
            PlanPhase.STRATEGIZE,
            f"auto-reversion: {reason}",
        )

    def try_auto_revert_from_execute(self, plan_id: str, reason: str) -> Plan:
        """Attempt automatic reversion from Execute to Strategize.

        Called when validation failures block apply and the automation
        profile permits reversion.  The ``delete_content`` task-type
        threshold controls this gate (strategy revision).

        Args:
            plan_id: The plan ULID.
            reason: Why the execute needs reversion.

        Returns:
            The (possibly reverted) Plan.
        """
        plan = self.get_plan(plan_id)

        if plan.phase != PlanPhase.EXECUTE:
            return plan

        profile = self._resolve_profile_for_plan(plan)
        if profile.delete_content >= 1.0:
            self._logger.info(
                "Execute-to-Strategize reversion blocked by profile threshold",
                plan_id=plan_id,
                threshold_field="delete_content",
                threshold=profile.delete_content,
            )
            return plan

        if plan.reversion_count >= plan.MAX_REVERSIONS:
            self._logger.warning(
                "Execute-to-Strategize reversion blocked by loop guard",
                plan_id=plan_id,
                reversion_count=plan.reversion_count,
                max_reversions=plan.MAX_REVERSIONS,
            )
            return plan

        self._logger.info(
            "Auto-reverting plan from Execute to Strategize",
            plan_id=plan_id,
            reason=reason,
        )

        return self._perform_reversion(
            plan,
            PlanPhase.STRATEGIZE,
            f"auto-reversion: {reason}",
        )

    def _perform_reversion(
        self,
        plan: Plan,
        to_phase: PlanPhase,
        reason: str,
    ) -> Plan:
        """Execute a phase reversion on a plan.

        Records the reversion decision, increments the reversion count,
        and resets the plan to the target phase in QUEUED state.

        Args:
            plan: The plan to revert.
            to_phase: Target phase.
            reason: Reason for the reversion.

        Returns:
            The updated Plan.
        """
        source_phase = plan.phase
        plan_id = plan.identity.plan_id

        # Record reversion decision
        decision: dict[str, Any] = {
            "type": "reversion",
            "source_phase": source_phase.value,
            "target_phase": to_phase.value,
            "reason": reason,
            "timestamp": datetime.now().isoformat(),
            "reversion_number": plan.reversion_count + 1,
        }
        plan.decisions.append(decision)

        # Update plan state — set processing_state first so that the
        # phase-state validator sees QUEUED (valid in any phase) when the
        # phase assignment triggers re-validation.
        plan.processing_state = ProcessingState.QUEUED
        plan.phase = to_phase
        plan.reversion_count += 1
        plan.error_message = None
        plan.timestamps.updated_at = datetime.now()

        self._commit_plan(plan)

        self._logger.info(
            "Plan reverted",
            plan_id=plan_id,
            from_phase=source_phase.value,
            to_phase=to_phase.value,
            reason=reason,
            reversion_count=plan.reversion_count,
        )

        return plan
