"""Repository implementations for CleverAgents.

Provides database-backed repositories following the session-factory pattern
(ADR-007) with retry logic (ADR-033).

## Repositories

| Repository                     | Purpose                                    |
|-------------------------------|--------------------------------------------|
| ``LifecyclePlanRepository``   | CRUD for v3 plans + child tables           |
| ``ActionRepository``          | CRUD for actions + arguments/invariants    |
| ``NamespacedProjectRepository``| CRUD for namespaced projects              |
| ``ProjectResourceLinkRepository``| Project-resource link management        |
| ``DecisionRepository``         | CRUD for decision tree nodes             |
| ``CorrectionAttemptRepository``| CRUD for correction attempt records      |

## Session-Factory Pattern

All repositories accept a ``session_factory`` callable that returns a
SQLAlchemy ``Session``. Callers are responsible for commit/rollback/close.

## UnitOfWork Integration

For cross-repository atomicity, use
[UnitOfWork][cleveragents.infrastructure.database.unit_of_work.UnitOfWork]:

```python
from cleveragents.infrastructure.database.unit_of_work import UnitOfWork

uow = UnitOfWork("sqlite:///my.db")
uow.init_database()

with uow:
    plan = uow.plans.get(plan_id)
    uow.plans.update(plan)
    uow.commit()
```

## Custom Exceptions

| Exception               | Description                                   |
|-------------------------|-----------------------------------------------|
| ``DuplicateActionError``| Action name already exists                    |
| ``ActionInUseError``    | Action has active plans; cannot delete         |
| ``PlanNotFoundError``   | Plan ID not found                             |
| ``ProjectNotFoundError``| Project not found                             |
| ``DuplicateLinkError``  | Project-resource link already exists           |
| ``DuplicateDecisionError``| Decision ID already exists                   |
| ``DecisionNotFoundError``| Decision ID not found                         |
| ``CorrectionAttemptNotFoundError``| Correction attempt not found          |
| ``DuplicateCorrectionAttemptError``| Correction attempt ID already exists |
| ``InvalidCorrectionStateTransitionError``| Invalid lifecycle transition |

Based on ADR-007 (Repository Pattern) and ADR-033 (Retry Patterns).
"""

from __future__ import annotations

import hashlib
import json
import warnings
from collections import deque
from collections.abc import Callable
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any, ClassVar, cast

if TYPE_CHECKING:
    from cleveragents.domain.models.core.checkpoint import Checkpoint

import structlog
from sqlalchemy import delete as sa_delete
from sqlalchemy import func as sa_func
from sqlalchemy.exc import DatabaseError as SQLAlchemyDatabaseError
from sqlalchemy.exc import IntegrityError, OperationalError
from sqlalchemy.orm import Session

from cleveragents.core.exceptions import (
    BusinessRuleViolation,
    DatabaseError,
)
from cleveragents.core.retry_patterns import retry_database_operation as database_retry
from cleveragents.domain.models.core import (
    Actor,
    Change,
    Context,
    DebugAttempt,
    Plan,
    Project,
    ProjectSettings,
)
from cleveragents.domain.models.core.skill import Skill
from cleveragents.domain.repositories.action_repository import ActionRepositoryProtocol
from cleveragents.domain.repositories.decision_repository import (
    DecisionRepositoryProtocol,
)
from cleveragents.domain.repositories.plan_repository import (
    LifecyclePlanRepositoryProtocol,
)
from cleveragents.domain.repositories.project_repository import (
    ProjectRepositoryProtocol,
)
from cleveragents.infrastructure.database.models import (
    ActionArgumentModel,
    ActionInvariantModel,
    ActorModel,
    AutomationProfileModel,
    ChangeModel,
    CheckpointModel,
    ContextModel,
    CorrectionAttemptModel,
    DebugAttemptModel,
    DecisionModel,
    LifecycleActionModel,
    LifecyclePlanModel,
    NamespacedProjectModel,
    PlanArgumentModel,
    PlanInvariantModel,
    PlanModel,
    PlanProjectModel,
    ProjectModel,
    ProjectResourceLinkModel,
    ResourceEdgeModel,
    ResourceLinkModel,
    ResourceModel,
    ResourceTypeModel,
    SessionMessageModel,
    SessionModel,
    SkillItemModel,
    SkillModel,
    ToolModel,
    ToolResourceBindingModel,
    ValidationAttachmentModel,
    format_sqlite_timestamp,
)

if TYPE_CHECKING:
    from cleveragents.domain.models.core.correction import (
        CorrectionAttemptRecord,
    )
    from cleveragents.domain.models.core.decision import Decision

from cleveragents.domain.models.core.correction import (
    CORRECTION_ATTEMPT_TERMINAL_STATES,
    CorrectionAttemptState,
    validate_correction_state_transition,
)

_log = structlog.get_logger(__name__)


class ProjectRepository:
    """Repository for project persistence."""

    def __init__(self, session: Session):
        """Initialize repository with database session."""
        self.session = session

    @database_retry
    def create(self, project: Project) -> Project:
        """Create a new project with retry logic for transient database errors."""
        try:
            db_project = ProjectModel(
                name=project.name,
                path=str(project.path),
                settings=project.settings.model_dump(),
            )

            self.session.add(db_project)
            self.session.flush()
            self.session.refresh(db_project)

            project.id = db_project.id  # type: ignore
            return project
        except (OperationalError, SQLAlchemyDatabaseError) as e:
            self.session.rollback()
            raise DatabaseError(f"Failed to create project: {e}") from e

    @database_retry
    def get_by_id(self, project_id: int) -> Project | None:
        """Get project by ID with retry logic."""
        try:
            db_project = (
                self.session.query(ProjectModel).filter_by(id=project_id).first()
            )

            if not db_project:
                return None

            return Project(
                id=db_project.id,  # type: ignore
                name=db_project.name,  # type: ignore
                path=Path(db_project.path),  # type: ignore
                created_at=db_project.created_at,  # type: ignore
                updated_at=db_project.updated_at,  # type: ignore
                settings=ProjectSettings(**db_project.settings),  # type: ignore
                current_plan_id=db_project.current_plan_id,  # type: ignore
            )
        except (OperationalError, SQLAlchemyDatabaseError) as e:
            raise DatabaseError(f"Failed to get project {project_id}: {e}") from e

    def get_by_name(self, name: str) -> Project | None:
        """Get project by name."""
        db_project = self.session.query(ProjectModel).filter_by(name=name).first()

        if not db_project:
            return None

        return Project(
            id=db_project.id,  # type: ignore
            name=db_project.name,  # type: ignore
            path=Path(db_project.path),  # type: ignore
            created_at=db_project.created_at,  # type: ignore
            updated_at=db_project.updated_at,  # type: ignore
            settings=ProjectSettings(**db_project.settings),  # type: ignore
            current_plan_id=db_project.current_plan_id,  # type: ignore
        )

    def get_all(self) -> list[Project]:
        """Get all projects."""
        db_projects = self.session.query(ProjectModel).all()

        return [
            Project(
                id=p.id,  # type: ignore
                name=p.name,  # type: ignore
                path=Path(p.path),  # type: ignore
                created_at=p.created_at,  # type: ignore
                updated_at=p.updated_at,  # type: ignore
                settings=ProjectSettings(**p.settings),  # type: ignore
                current_plan_id=p.current_plan_id,  # type: ignore
            )
            for p in db_projects
        ]

    def update(self, project: Project) -> Project:
        """Update an existing project."""
        db_project = self.session.query(ProjectModel).filter_by(id=project.id).first()

        if db_project:
            db_project.name = project.name  # type: ignore
            db_project.path = str(project.path)  # type: ignore
            db_project.settings = project.settings.model_dump()  # type: ignore
            db_project.current_plan_id = project.current_plan_id  # type: ignore
            db_project.updated_at = datetime.now()  # type: ignore

            self.session.flush()

        return project

    def delete(self, project_id: int) -> None:
        """Delete a project by ID."""
        db_project = self.session.query(ProjectModel).filter_by(id=project_id).first()
        if db_project:
            self.session.delete(db_project)
            self.session.flush()


class PlanRepository:
    """Repository for plan persistence.

    .. deprecated::
        ``PlanRepository`` targets the legacy ``plans`` table and will be
        removed in a future release.  Use ``LifecyclePlanRepository`` for
        v3 lifecycle plans.
    """

    def __init__(self, session: Session):
        """Initialize repository with database session."""
        warnings.warn(
            "PlanRepository is deprecated. Use LifecyclePlanRepository for "
            "v3 lifecycle plans.",
            DeprecationWarning,
            stacklevel=2,
        )
        self.session = session

    def create(self, plan: Plan) -> Plan:
        """Create a new plan."""
        db_plan = PlanModel(
            project_id=plan.project_id,
            name=plan.name,
            prompt=plan.prompt,
            status=plan.status,
            current=plan.current,
            # Backward compatibility fields
            build_started_at=plan.build_started_at,
            build_completed_at=plan.build_completed_at,
            model_used=plan.model_used,
            token_count=plan.token_count,
            applied_at=plan.applied_at,
            files_created=plan.files_created,
            files_modified=plan.files_modified,
            files_deleted=plan.files_deleted,
            error_message=plan.build.error_message if plan.build else None,
        )

        self.session.add(db_plan)
        self.session.flush()
        self.session.refresh(db_plan)

        plan.id = db_plan.id  # type: ignore
        return plan

    def get_by_id(self, plan_id: int) -> Plan | None:
        """Get plan by ID."""
        db_plan = self.session.query(PlanModel).filter_by(id=plan_id).first()

        if not db_plan:
            return None

        return self._to_domain(db_plan)

    def get_current_for_project(self, project_id: int) -> Plan | None:
        """Get current plan for a project."""
        db_plan = (
            self.session.query(PlanModel)
            .filter_by(project_id=project_id, current=True)
            .first()
        )

        if not db_plan:
            return None

        return self._to_domain(db_plan)

    def get_all_for_project(self, project_id: int) -> list[Plan]:
        """Get all plans for a project."""
        db_plans = self.session.query(PlanModel).filter_by(project_id=project_id).all()
        return [self._to_domain(p) for p in db_plans]

    def update(self, plan: Plan) -> Plan:
        """Update an existing plan."""
        db_plan = self.session.query(PlanModel).filter_by(id=plan.id).first()

        if db_plan:
            db_plan.name = plan.name  # type: ignore
            db_plan.prompt = plan.prompt  # type: ignore
            db_plan.status = plan.status  # type: ignore
            db_plan.current = plan.current  # type: ignore
            db_plan.updated_at = datetime.now()  # type: ignore

            # Handle backward compatibility fields first
            if plan.build_started_at is not None:
                db_plan.build_started_at = plan.build_started_at  # type: ignore
            if plan.build_completed_at is not None:
                db_plan.build_completed_at = plan.build_completed_at  # type: ignore
            if plan.model_used is not None:
                db_plan.model_used = plan.model_used  # type: ignore
            if plan.token_count is not None:
                db_plan.token_count = plan.token_count  # type: ignore
            if plan.applied_at is not None:
                db_plan.applied_at = plan.applied_at  # type: ignore
            if plan.files_created is not None:
                db_plan.files_created = plan.files_created  # type: ignore
            if plan.files_modified is not None:
                db_plan.files_modified = plan.files_modified  # type: ignore
            if plan.files_deleted is not None:
                db_plan.files_deleted = plan.files_deleted  # type: ignore

            # Then handle build/result objects (they override backward compat fields)
            if plan.build:
                db_plan.build_started_at = plan.build.started_at  # type: ignore
                db_plan.build_completed_at = plan.build.completed_at  # type: ignore
                db_plan.model_used = plan.build.model_used  # type: ignore
                db_plan.token_count = plan.build.token_count  # type: ignore
                db_plan.error_message = plan.build.error_message  # type: ignore

            if plan.result:
                db_plan.applied_at = plan.result.applied_at  # type: ignore
                db_plan.files_created = plan.result.files_created  # type: ignore
                db_plan.files_modified = plan.result.files_modified  # type: ignore
                db_plan.files_deleted = plan.result.files_deleted  # type: ignore

            self.session.flush()

        return plan

    def set_current(self, project_id: int, plan_id: int) -> None:
        """Set a plan as current for a project."""
        # Clear current flag on all plans for project
        self.session.query(PlanModel).filter_by(project_id=project_id).update(
            {"current": False}
        )

        # Set new current plan
        self.session.query(PlanModel).filter_by(id=plan_id).update({"current": True})

        self.session.flush()

    def get_current(self, project_id: int) -> Plan | None:
        """Get the current plan for a project."""
        db_plan = (
            self.session.query(PlanModel)
            .filter_by(project_id=project_id, current=True)
            .first()
        )

        if not db_plan:
            return None

        return self._to_domain(db_plan)

    def _to_domain(self, db_plan: PlanModel) -> Plan:
        """Convert database model to domain model."""
        from cleveragents.domain.models.core import PlanBuild, PlanResult

        build = None
        if db_plan.build_started_at:  # type: ignore
            build = PlanBuild(
                id=None,
                plan_id=db_plan.id,  # type: ignore
                started_at=db_plan.build_started_at,  # type: ignore
                completed_at=db_plan.build_completed_at,  # type: ignore
                model_used=db_plan.model_used or "",  # type: ignore
                token_count=db_plan.token_count or 0,  # type: ignore
                error_message=db_plan.error_message,  # type: ignore
            )

        result = None
        if db_plan.applied_at:  # type: ignore
            result = PlanResult(
                success=db_plan.error_message is None,  # type: ignore
                files_created=db_plan.files_created or 0,  # type: ignore
                files_modified=db_plan.files_modified or 0,  # type: ignore
                files_deleted=db_plan.files_deleted or 0,  # type: ignore
                error_message=db_plan.error_message,  # type: ignore
                applied_at=db_plan.applied_at,  # type: ignore
            )

        return Plan(
            id=db_plan.id,  # type: ignore
            project_id=db_plan.project_id,  # type: ignore
            name=db_plan.name,  # type: ignore
            prompt=db_plan.prompt,  # type: ignore
            status=db_plan.status,  # type: ignore
            current=db_plan.current,  # type: ignore
            created_at=db_plan.created_at,  # type: ignore
            updated_at=db_plan.updated_at,  # type: ignore
            build=build,
            result=result,
            # Backward compatibility fields
            build_started_at=db_plan.build_started_at,  # type: ignore
            build_completed_at=db_plan.build_completed_at,  # type: ignore
            model_used=db_plan.model_used,  # type: ignore
            token_count=db_plan.token_count,  # type: ignore
            applied_at=db_plan.applied_at,  # type: ignore
            files_created=db_plan.files_created,  # type: ignore
            files_modified=db_plan.files_modified,  # type: ignore
            files_deleted=db_plan.files_deleted,  # type: ignore
        )


class ContextRepository:
    """Repository for context persistence."""

    def __init__(self, session: Session):
        """Initialize repository with database session."""
        self.session = session

    def add(self, context: Context) -> Context:
        """Add a context item."""
        db_context = ContextModel(
            plan_id=context.plan_id,
            type=context.type,
            path=context.path,
            content=context.content,
            file_hash=context.file_hash,
            size=context.size,
        )

        self.session.add(db_context)
        self.session.flush()
        self.session.refresh(db_context)

        context.id = db_context.id  # type: ignore
        return context

    def get_for_plan(self, plan_id: int) -> list[Context]:
        """Get all context items for a plan."""
        db_contexts = self.session.query(ContextModel).filter_by(plan_id=plan_id).all()

        return [
            Context(
                id=c.id,  # type: ignore
                plan_id=c.plan_id,  # type: ignore
                type=c.type,  # type: ignore
                path=c.path,  # type: ignore
                content=c.content,  # type: ignore
                file_hash=c.file_hash,  # type: ignore
                size=c.size,  # type: ignore
                added_at=c.added_at,  # type: ignore
            )
            for c in db_contexts
        ]

    def get_all(self) -> list[Context]:
        """Get all context items."""
        db_contexts = self.session.query(ContextModel).all()

        return [
            Context(
                id=c.id,  # type: ignore
                plan_id=c.plan_id,  # type: ignore
                type=c.type,  # type: ignore
                path=c.path,  # type: ignore
                content=c.content,  # type: ignore
                file_hash=c.file_hash,  # type: ignore
                size=c.size,  # type: ignore
                added_at=c.added_at,  # type: ignore
            )
            for c in db_contexts
        ]

    def remove(self, context_id: int) -> None:
        """Remove a context item."""
        self.session.query(ContextModel).filter_by(id=context_id).delete()
        self.session.flush()

    def clear_for_plan(self, plan_id: int) -> None:
        """Clear all context for a plan."""
        self.session.query(ContextModel).filter_by(plan_id=plan_id).delete()
        self.session.flush()


class ChangeRepository:
    """Repository for change persistence.

    .. deprecated::
        ``ChangeRepository`` targets the legacy ``changes`` table and will
        be replaced by tool-based ``ChangeSet`` tracking in a future
        release.  Prefer the lifecycle-based plan workflow.
    """

    def __init__(self, session: Session):
        """Initialize repository with database session."""
        warnings.warn(
            "ChangeRepository is deprecated. It will be replaced by "
            "tool-based ChangeSet tracking in a future release.",
            DeprecationWarning,
            stacklevel=2,
        )
        self.session = session

    def add(self, change: Change) -> Change:
        """Add a change."""
        db_change = ChangeModel(
            plan_id=change.plan_id,
            file_path=change.file_path,
            operation=change.operation,
            original_content=change.original_content,
            new_content=change.new_content,
            new_path=change.new_path,
            applied=change.applied,
        )

        self.session.add(db_change)
        self.session.flush()
        self.session.refresh(db_change)

        change.id = db_change.id  # type: ignore
        return change

    def get_for_plan(self, plan_id: int) -> list[Change]:
        """Get all changes for a plan."""
        db_changes = self.session.query(ChangeModel).filter_by(plan_id=plan_id).all()

        return [
            Change(
                id=c.id,  # type: ignore
                plan_id=c.plan_id,  # type: ignore
                file_path=c.file_path,  # type: ignore
                operation=c.operation,  # type: ignore
                original_content=c.original_content,  # type: ignore
                new_content=c.new_content,  # type: ignore
                new_path=c.new_path,  # type: ignore
                applied=c.applied,  # type: ignore
                created_at=c.created_at,  # type: ignore
                applied_at=c.applied_at,  # type: ignore
            )
            for c in db_changes
        ]

    def get_all(self) -> list[Change]:
        """Get all changes."""
        db_changes = self.session.query(ChangeModel).all()

        return [
            Change(
                id=c.id,  # type: ignore
                plan_id=c.plan_id,  # type: ignore
                file_path=c.file_path,  # type: ignore
                operation=c.operation,  # type: ignore
                original_content=c.original_content,  # type: ignore
                new_content=c.new_content,  # type: ignore
                new_path=c.new_path,  # type: ignore
                applied=c.applied,  # type: ignore
                created_at=c.created_at,  # type: ignore
                applied_at=c.applied_at,  # type: ignore
            )
            for c in db_changes
        ]

    def mark_applied(self, change_id: int) -> None:
        """Mark a change as applied."""
        self.session.query(ChangeModel).filter_by(id=change_id).update(
            {"applied": True, "applied_at": datetime.now()}
        )
        self.session.flush()

    def clear_for_plan(self, plan_id: int) -> None:
        """Clear all changes for a plan."""
        self.session.query(ChangeModel).filter_by(plan_id=plan_id).delete()
        self.session.flush()


class DebugAttemptRepository:
    """Repository for debug attempt persistence."""

    def __init__(self, session: Session):
        """Initialize repository with database session."""
        self.session = session

    def add(self, debug_attempt: DebugAttempt) -> DebugAttempt:
        """Add a debug attempt."""
        db_debug_attempt = DebugAttemptModel(
            plan_id=debug_attempt.plan_id,
            error_message=debug_attempt.error_message,
            attempted_fix=debug_attempt.attempted_fix,
            success=debug_attempt.success,
            attempt_number=debug_attempt.attempt_number,
            created_at=debug_attempt.created_at,
        )

        self.session.add(db_debug_attempt)
        self.session.flush()
        self.session.refresh(db_debug_attempt)

        debug_attempt.id = db_debug_attempt.id  # type: ignore
        return debug_attempt

    def get(self, debug_attempt_id: int) -> DebugAttempt | None:
        """Get a specific debug attempt by ID."""
        db_attempt = (
            self.session.query(DebugAttemptModel).filter_by(id=debug_attempt_id).first()
        )

        if not db_attempt:
            return None

        return DebugAttempt(
            id=db_attempt.id,  # type: ignore
            plan_id=db_attempt.plan_id,  # type: ignore
            error_message=db_attempt.error_message,  # type: ignore
            attempted_fix=db_attempt.attempted_fix,  # type: ignore
            success=db_attempt.success,  # type: ignore
            attempt_number=db_attempt.attempt_number,  # type: ignore
            created_at=db_attempt.created_at,  # type: ignore
        )

    def update(self, debug_attempt: DebugAttempt) -> DebugAttempt:
        """Update an existing debug attempt."""
        db_attempt = (
            self.session.query(DebugAttemptModel).filter_by(id=debug_attempt.id).first()
        )

        if not db_attempt:
            raise ValueError(f"Debug attempt with id {debug_attempt.id} not found")

        db_attempt.error_message = debug_attempt.error_message  # type: ignore
        db_attempt.attempted_fix = debug_attempt.attempted_fix  # type: ignore
        db_attempt.success = debug_attempt.success  # type: ignore
        db_attempt.attempt_number = debug_attempt.attempt_number  # type: ignore

        self.session.flush()
        self.session.refresh(db_attempt)

        return debug_attempt

    def get_for_plan(self, plan_id: int) -> list[DebugAttempt]:
        """Get all debug attempts for a plan."""
        db_attempts = (
            self.session.query(DebugAttemptModel).filter_by(plan_id=plan_id).all()
        )

        return [
            DebugAttempt(
                id=a.id,  # type: ignore
                plan_id=a.plan_id,  # type: ignore
                error_message=a.error_message,  # type: ignore
                attempted_fix=a.attempted_fix,  # type: ignore
                success=a.success,  # type: ignore
                attempt_number=a.attempt_number,  # type: ignore
                created_at=a.created_at,  # type: ignore
            )
            for a in db_attempts
        ]

    def get_all(self) -> list[DebugAttempt]:
        """Get all debug attempts."""
        db_attempts = self.session.query(DebugAttemptModel).all()

        return [
            DebugAttempt(
                id=a.id,  # type: ignore
                plan_id=a.plan_id,  # type: ignore
                error_message=a.error_message,  # type: ignore
                attempted_fix=a.attempted_fix,  # type: ignore
                success=a.success,  # type: ignore
                attempt_number=a.attempt_number,  # type: ignore
                created_at=a.created_at,  # type: ignore
            )
            for a in db_attempts
        ]

    def clear_for_plan(self, plan_id: int) -> None:
        """Clear all debug attempts for a plan."""
        self.session.query(DebugAttemptModel).filter_by(plan_id=plan_id).delete()
        self.session.flush()


class ActorRepository:
    """Repository for actor persistence."""

    def __init__(self, session: Session):
        self.session = session

    def _to_domain(self, model: ActorModel) -> Actor:
        return Actor(
            id=model.id,  # type: ignore[arg-type]
            name=model.name,  # type: ignore[arg-type]
            provider=model.provider,  # type: ignore[arg-type]
            model=model.model,  # type: ignore[arg-type]
            config_blob=model.config_blob or {},  # type: ignore[arg-type]
            config_hash=model.config_hash,  # type: ignore[arg-type]
            graph_descriptor=model.graph_descriptor or None,  # type: ignore[arg-type]
            yaml_text=model.yaml_text,  # type: ignore[arg-type]
            schema_version=model.schema_version or "1.0",  # type: ignore[arg-type]
            compiled_metadata=model.compiled_metadata or None,  # type: ignore[arg-type]
            unsafe=model.unsafe,  # type: ignore[arg-type]
            is_built_in=model.is_built_in,  # type: ignore[arg-type]
            is_default=model.is_default,  # type: ignore[arg-type]
            created_at=model.created_at,  # type: ignore[arg-type]
            updated_at=model.updated_at,  # type: ignore[arg-type]
        )

    def get_by_name(self, name: str) -> Actor | None:
        db_actor = self.session.query(ActorModel).filter_by(name=name).first()
        return self._to_domain(db_actor) if db_actor else None

    def list_all(self) -> list[Actor]:
        db_actors = self.session.query(ActorModel).order_by(ActorModel.name).all()
        return [self._to_domain(actor) for actor in db_actors]

    def upsert(self, actor: Actor) -> Actor:
        existing = cast(
            Any, self.session.query(ActorModel).filter_by(name=actor.name).first()
        )

        if existing:
            if bool(getattr(existing, "is_built_in", False)) and not actor.is_built_in:
                raise ValueError("Cannot overwrite built-in actor with custom entry")

            existing.provider = actor.provider
            existing.model = actor.model
            existing.config_blob = actor.config_blob
            existing.config_hash = actor.config_hash
            existing.graph_descriptor = actor.graph_descriptor
            existing.yaml_text = actor.yaml_text
            existing.schema_version = actor.schema_version
            existing.compiled_metadata = actor.compiled_metadata
            existing.unsafe = actor.unsafe
            existing.is_built_in = actor.is_built_in
            existing.is_default = actor.is_default
            existing.updated_at = datetime.now()
            self.session.flush()
            self.session.refresh(existing)
            actor.id = cast(Any, existing).id  # type: ignore[assignment]
            return actor

        db_actor = ActorModel(
            name=actor.name,
            provider=actor.provider,
            model=actor.model,
            config_blob=actor.config_blob,
            config_hash=actor.config_hash,
            graph_descriptor=actor.graph_descriptor,
            yaml_text=actor.yaml_text,
            schema_version=actor.schema_version,
            compiled_metadata=actor.compiled_metadata,
            unsafe=actor.unsafe,
            is_built_in=actor.is_built_in,
            is_default=actor.is_default,
            created_at=actor.created_at,
            updated_at=actor.updated_at,
        )
        self.session.add(db_actor)
        self.session.flush()
        self.session.refresh(db_actor)
        actor.id = cast(Any, db_actor).id  # type: ignore[assignment]
        return actor

    def upsert_built_in(self, actor: Actor) -> Actor:
        actor.is_built_in = True
        return self.upsert(actor)

    def delete(self, name: str) -> None:
        db_actor = cast(
            Any, self.session.query(ActorModel).filter_by(name=name).first()
        )
        if not db_actor:
            return
        if bool(getattr(db_actor, "is_built_in", False)):
            raise ValueError("Cannot delete built-in actors")
        if bool(getattr(db_actor, "is_default", False)):
            raise ValueError("Cannot delete the default actor")
        self.session.delete(cast(ActorModel, db_actor))
        self.session.flush()

    def clear_default(self) -> None:
        self.session.query(ActorModel).filter_by(is_default=True).update(
            {"is_default": False, "updated_at": datetime.now()}
        )
        self.session.flush()

    def set_default(self, name: str) -> Actor:
        db_actor = cast(
            Any, self.session.query(ActorModel).filter_by(name=name).first()
        )
        if not db_actor:
            raise ValueError(f"Actor {name} not found")
        self.clear_default()
        db_actor.is_default = True
        db_actor.updated_at = datetime.now()
        self.session.flush()
        self.session.refresh(db_actor)
        return self._to_domain(cast(ActorModel, db_actor))

    def get_default(self) -> Actor | None:
        db_actor = self.session.query(ActorModel).filter_by(is_default=True).first()
        return self._to_domain(db_actor) if db_actor else None

    def list_by_namespace(self, namespace: str) -> list[Actor]:
        """List actors whose name starts with the given namespace prefix."""
        db_actors = (
            self.session.query(ActorModel)
            .filter(ActorModel.name.startswith(f"{namespace}/"))
            .order_by(ActorModel.name)
            .all()
        )
        return [self._to_domain(actor) for actor in db_actors]

    def list_by_schema_version(self, schema_version: str) -> list[Actor]:
        """List actors matching a specific schema version."""
        db_actors = (
            self.session.query(ActorModel)
            .filter_by(schema_version=schema_version)
            .order_by(ActorModel.name)
            .all()
        )
        return [self._to_domain(actor) for actor in db_actors]


# ---------------------------------------------------------------------------
# V3 Action Repository
# ---------------------------------------------------------------------------


class DuplicateActionError(DatabaseError):
    """Raised when creating an action with a name that already exists."""

    def __init__(self, name: str):
        super().__init__(f"Action with name '{name}' already exists")
        self.action_name = name


class ActionInUseError(BusinessRuleViolation):
    """Raised when deleting an action that is still referenced by plans."""

    def __init__(self, action_name: str, plan_count: int):
        super().__init__(
            f"Cannot delete action {action_name}: "
            f"still referenced by {plan_count} plan(s)"
        )
        self.action_name = action_name
        self.plan_count = plan_count


class ActionRepository(ActionRepositoryProtocol):
    """Repository for v3 lifecycle action persistence.

    Implements :class:`~cleveragents.domain.repositories.ActionRepositoryProtocol`
    — the domain-layer port for action persistence.

    Uses a session-factory pattern: each public method obtains its own
    session from the factory, ensuring proper session lifecycle management.

    All mutating methods flush (but do NOT commit); the caller or a
    ``UnitOfWork`` wrapper is responsible for committing the transaction.
    """

    def __init__(self, session_factory: Callable[[], Session]) -> None:
        """Initialise with a callable that returns a new SQLAlchemy Session."""
        self._session_factory = session_factory

    def _session(self) -> Session:
        """Convenience helper to obtain a session."""
        return self._session_factory()

    @database_retry
    def create(self, action: Any) -> Any:
        """Persist a new ``Action`` domain object.

        Args:
            action: An ``Action`` domain model instance.

        Returns:
            The same ``Action`` after persistence.

        Raises:
            DuplicateActionError: If an action with the same namespaced name
                already exists.
            DatabaseError: On transient or unexpected DB errors.
        """
        session = self._session()
        try:
            db_model = LifecycleActionModel.from_domain(action)
            session.add(db_model)
            session.flush()
            return action
        except IntegrityError as exc:
            session.rollback()
            # Unique constraint on ``name`` column
            if "UNIQUE" in str(exc).upper() or "unique" in str(exc).lower():
                raise DuplicateActionError(str(action.namespaced_name)) from exc
            raise DatabaseError(f"Failed to create action: {exc}") from exc
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            session.rollback()
            raise DatabaseError(f"Failed to create action: {exc}") from exc

    @database_retry
    def get_by_id(self, action_name: str) -> Any | None:
        """Retrieve an action by its namespaced name (PK).

        Returns:
            The ``Action`` domain object, or ``None`` if not found.
        """
        session = self._session()
        try:
            row = (
                session.query(LifecycleActionModel)
                .filter_by(namespaced_name=action_name)
                .first()
            )
            if row is None:
                return None
            return row.to_domain()
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            raise DatabaseError(f"Failed to get action {action_name}: {exc}") from exc

    @database_retry
    def get_by_name(self, name: str) -> Any | None:
        """Retrieve an action by its full namespaced name string.

        Used for ``agents action show local/my-action``.

        Returns:
            The ``Action`` domain object, or ``None`` if not found.
        """
        session = self._session()
        try:
            row = (
                session.query(LifecycleActionModel)
                .filter_by(namespaced_name=name)
                .first()
            )
            if row is None:
                return None
            return row.to_domain()
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            raise DatabaseError(
                f"Failed to get action by name '{name}': {exc}"
            ) from exc

    @database_retry
    def get_by_namespace(
        self,
        namespace: str,
        state: str | None = None,
    ) -> list[Any]:
        """List actions in a namespace, optionally filtered by state.

        Results are ordered by ``short_name`` ASC for consistent display.

        Args:
            namespace: The namespace to filter by (e.g. ``"local"``).
            state: Optional ``ActionState`` value string to filter by.

        Returns:
            List of ``Action`` domain objects.
        """
        session = self._session()
        try:
            query = session.query(LifecycleActionModel).filter_by(namespace=namespace)
            if state is not None:
                query = query.filter_by(state=state)
            rows = query.order_by(LifecycleActionModel.name).all()
            return [row.to_domain() for row in rows]
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            raise DatabaseError(
                f"Failed to list actions in namespace '{namespace}': {exc}"
            ) from exc

    @database_retry
    def list_all(self) -> list[Any]:
        """List all persisted actions, ordered by namespace then name.

        Returns:
            List of ``Action`` domain objects.
        """
        session = self._session()
        try:
            rows = (
                session.query(LifecycleActionModel)
                .order_by(
                    LifecycleActionModel.namespace,
                    LifecycleActionModel.name,
                )
                .all()
            )
            return [row.to_domain() for row in rows]
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            raise DatabaseError(f"Failed to list all actions: {exc}") from exc

    @database_retry
    def get_by_state(self, state: str) -> list[Any]:
        """List actions by state, ordered by ``updated_at`` DESC.

        Args:
            state: An ``ActionState`` value string (e.g. ``"available"``).

        Returns:
            List of ``Action`` domain objects.
        """
        session = self._session()
        try:
            rows = (
                session.query(LifecycleActionModel)
                .filter_by(state=state)
                .order_by(LifecycleActionModel.updated_at.desc())
                .all()
            )
            return [row.to_domain() for row in rows]
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            raise DatabaseError(
                f"Failed to list actions by state '{state}': {exc}"
            ) from exc

    @database_retry
    def update(self, action: Any) -> Any:
        """Update all mutable fields of an existing action.

        The namespaced name (stored in ``action_id`` PK column) is used to
        locate the row.  The ``updated_at`` timestamp is refreshed
        automatically.

        Args:
            action: The ``Action`` domain model with updated fields.

        Returns:
            The same ``Action`` after persistence.

        Raises:
            DatabaseError: If the action is not found or a DB error occurs.
        """
        session = self._session()
        action_name_str = str(action.namespaced_name)
        try:
            row = (
                session.query(LifecycleActionModel)
                .filter_by(namespaced_name=action_name_str)
                .first()
            )
            if row is None:
                raise DatabaseError(f"Action {action_name_str} not found for update")

            # Overwrite all mutable columns from the domain model
            import json as _json

            ns = action.namespaced_name
            row.namespace = ns.namespace  # type: ignore[assignment]
            row.name = ns.name  # type: ignore[assignment]
            row.description = action.description  # type: ignore[assignment]
            row.long_description = action.long_description  # type: ignore[assignment]
            row.definition_of_done = action.definition_of_done  # type: ignore[assignment]
            row.strategy_actor = action.strategy_actor  # type: ignore[assignment]
            row.execution_actor = action.execution_actor  # type: ignore[assignment]
            row.estimation_actor = action.estimation_actor  # type: ignore[assignment]
            row.review_actor = action.review_actor  # type: ignore[assignment]
            row.apply_actor = getattr(action, "apply_actor", None)  # type: ignore[assignment]
            row.invariant_actor = getattr(action, "invariant_actor", None)  # type: ignore[assignment]
            row.automation_profile = getattr(action, "automation_profile", None)  # type: ignore[assignment]
            inputs_json: str | None = None
            if action.inputs_schema is not None:
                inputs_json = _json.dumps(action.inputs_schema)
            row.inputs_schema_json = inputs_json  # type: ignore[assignment]
            row.state = (  # type: ignore[assignment]
                action.state.value if hasattr(action.state, "value") else action.state
            )
            row.reusable = action.reusable  # type: ignore[assignment]
            row.read_only = action.read_only  # type: ignore[assignment]
            row.created_by = action.created_by  # type: ignore[assignment]
            row.tags_json = _json.dumps(action.tags)  # type: ignore[assignment]
            row.updated_at = datetime.now().isoformat()  # type: ignore[assignment]

            # Update child arguments (replace all).
            #
            # We use explicit bulk DELETE + flush before re-inserting
            # to avoid a UNIQUE constraint violation on
            # (action_name, name).  SQLAlchemy's relationship .clear()
            # defers the DELETE and may process the INSERT first,
            # which triggers an IntegrityError when the same
            # (action_name, name) pair is being re-inserted.

            session.execute(
                sa_delete(ActionArgumentModel)
                .where(ActionArgumentModel.action_name == action_name_str)
                .execution_options(synchronize_session=False)
            )
            session.execute(
                sa_delete(ActionInvariantModel)
                .where(ActionInvariantModel.action_name == action_name_str)
                .execution_options(synchronize_session=False)
            )
            session.flush()

            # Expire the relationship collections so SQLAlchemy reloads from
            # the database (now empty) before we append freshly constructed
            # replacements.  This avoids reusing stale
            # ``ActionArgumentModel``/``ActionInvariantModel`` instances that
            # remain in the identity map after the bulk delete.
            session.expire(row, ["arguments_rel", "invariants_rel"])

            for idx, arg in enumerate(action.arguments):
                default_json: str | None = None
                if arg.default_value is not None:
                    default_json = _json.dumps(arg.default_value)
                row.arguments_rel.append(  # type: ignore[union-attr]
                    ActionArgumentModel(
                        name=arg.name,
                        arg_type=arg.arg_type.value
                        if hasattr(arg.arg_type, "value")
                        else arg.arg_type,
                        requirement=arg.requirement.value
                        if hasattr(arg.requirement, "value")
                        else arg.requirement,
                        description=arg.description,
                        default_value_json=default_json,
                        min_value=arg.min_value,
                        max_value=arg.max_value,
                        validation_pattern=arg.validation_pattern,
                        position=idx,
                    )
                )

            # Re-insert child invariants
            now_iso = datetime.now().isoformat()
            for idx, inv_text in enumerate(getattr(action, "invariants", []) or []):
                row.invariants_rel.append(  # type: ignore[union-attr]
                    ActionInvariantModel(
                        invariant_text=inv_text,
                        position=idx,
                        created_at=now_iso,
                    )
                )

            session.flush()
            return action
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            session.rollback()
            raise DatabaseError(
                f"Failed to update action {action_name_str}: {exc}"
            ) from exc

    @database_retry
    def list_available(self, namespace: str | None = None) -> list[Any]:
        """List all actions in ``available`` state.

        Ordered by ``namespace`` ASC, ``short_name`` ASC.

        Args:
            namespace: Optional namespace filter.

        Returns:
            List of ``Action`` domain objects.
        """
        session = self._session()
        try:
            query = session.query(LifecycleActionModel).filter_by(state="available")
            if namespace is not None:
                query = query.filter_by(namespace=namespace)
            rows = query.order_by(
                LifecycleActionModel.namespace,
                LifecycleActionModel.name,
            ).all()
            return [row.to_domain() for row in rows]
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            raise DatabaseError(f"Failed to list available actions: {exc}") from exc

    @database_retry
    def delete(self, action_name: str) -> bool:
        """Delete an action by namespaced name.

        Before deletion, verifies the action is not referenced by any
        lifecycle plans.

        Args:
            action_name: The namespaced name string of the action to delete.

        Returns:
            ``True`` if the action was deleted.

        Raises:
            ActionInUseError: If plans still reference this action.
            DatabaseError: On transient or unexpected DB errors.
        """
        from cleveragents.infrastructure.database.models import (
            LifecyclePlanModel,
        )

        session = self._session()
        try:
            # Check referential integrity (action_name column in v3_plans)
            plan_count: int = (
                session.query(LifecyclePlanModel)
                .filter(LifecyclePlanModel.action_name == action_name)
                .count()
            )
            if plan_count > 0:
                raise ActionInUseError(action_name, plan_count)

            row = (
                session.query(LifecycleActionModel)
                .filter_by(namespaced_name=action_name)
                .first()
            )
            if row is None:
                return False
            session.delete(row)
            session.flush()
            return True
        except ActionInUseError:
            raise
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            session.rollback()
            raise DatabaseError(
                f"Failed to delete action {action_name}: {exc}"
            ) from exc


# ---------------------------------------------------------------------------
# V3 Lifecycle Plan Repository
# ---------------------------------------------------------------------------


class DuplicatePlanError(DatabaseError):
    """Raised when creating a plan with an ID that already exists."""

    def __init__(self, plan_id: str):
        super().__init__(f"Plan with id '{plan_id}' already exists")
        self.plan_id = plan_id


class PlanNotFoundError(DatabaseError):
    """Raised when a plan cannot be found for update/delete."""

    def __init__(self, plan_id: str):
        super().__init__(f"Plan '{plan_id}' not found")
        self.plan_id = plan_id


class LifecyclePlanRepository(LifecyclePlanRepositoryProtocol):
    """Repository for v3 lifecycle plan persistence.

    Implements
    :class:`~cleveragents.domain.repositories.LifecyclePlanRepositoryProtocol`
    — the domain-layer port for lifecycle plan persistence.

    Uses a session-factory pattern: each public method obtains its own
    session from the factory, ensuring proper session lifecycle management.

    All mutating methods flush (but do NOT commit); the caller or a
    ``UnitOfWork`` wrapper is responsible for committing the transaction.
    """

    def __init__(self, session_factory: Callable[[], Session]) -> None:
        """Initialise with a callable that returns a new SQLAlchemy Session."""
        self._session_factory = session_factory

    def _session(self) -> Session:
        """Convenience helper to obtain a session."""
        return self._session_factory()

    @database_retry
    def create(self, plan: Any) -> Any:
        """Persist a new ``Plan`` domain object.

        Args:
            plan: A v3 ``Plan`` domain model instance.

        Returns:
            The same ``Plan`` after persistence.

        Raises:
            DuplicatePlanError: If a plan with the same ID already exists.
            DatabaseError: On transient or unexpected DB errors.
        """
        session = self._session()
        try:
            db_model = LifecyclePlanModel.from_domain(plan)
            session.add(db_model)
            session.flush()
            return plan
        except IntegrityError as exc:
            session.rollback()
            exc_str = str(exc).upper()
            if "UNIQUE" in exc_str or "PRIMARY" in exc_str:
                raise DuplicatePlanError(str(plan.identity.plan_id)) from exc
            raise DatabaseError(f"Failed to create plan: {exc}") from exc
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            session.rollback()
            raise DatabaseError(f"Failed to create plan: {exc}") from exc

    @database_retry
    def get(self, plan_id: str) -> Any | None:
        """Retrieve a plan by its ULID primary key.

        Returns:
            The ``Plan`` domain object, or ``None`` if not found.
        """
        session = self._session()
        try:
            row = session.query(LifecyclePlanModel).filter_by(plan_id=plan_id).first()
            if row is None:
                return None
            return row.to_domain()
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            raise DatabaseError(f"Failed to get plan {plan_id}: {exc}") from exc

    @database_retry
    def get_by_name(self, namespaced_name: str) -> Any | None:
        """Retrieve a plan by its full namespaced name string.

        Returns:
            The ``Plan`` domain object, or ``None`` if not found.
        """
        session = self._session()
        try:
            row = (
                session.query(LifecyclePlanModel)
                .filter_by(namespaced_name=namespaced_name)
                .first()
            )
            if row is None:
                return None
            return row.to_domain()
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            raise DatabaseError(
                f"Failed to get plan by name '{namespaced_name}': {exc}"
            ) from exc

    @database_retry
    def update(self, plan: Any) -> Any:
        """Update all mutable fields of an existing plan.

        Args:
            plan: The ``Plan`` domain model with updated fields.

        Returns:
            The same ``Plan`` after persistence.

        Raises:
            PlanNotFoundError: If the plan is not found.
            DatabaseError: On transient or unexpected DB errors.
        """
        session = self._session()
        plan_id_str = str(plan.identity.plan_id)
        try:
            row = (
                session.query(LifecyclePlanModel).filter_by(plan_id=plan_id_str).first()
            )
            if row is None:
                raise PlanNotFoundError(plan_id_str)

            # Overwrite mutable columns from domain model
            row.phase = (  # type: ignore[assignment]
                plan.phase.value if hasattr(plan.phase, "value") else plan.phase
            )
            row.processing_state = (  # type: ignore[assignment]
                plan.processing_state.value
                if hasattr(plan.processing_state, "value")
                else str(plan.processing_state)
            )
            row.attempt = plan.identity.attempt  # type: ignore[assignment]
            row.description = plan.description  # type: ignore[assignment]
            row.definition_of_done = plan.definition_of_done  # type: ignore[assignment]
            row.strategy_actor = plan.strategy_actor  # type: ignore[assignment]
            row.execution_actor = plan.execution_actor  # type: ignore[assignment]
            row.review_actor = getattr(plan, "review_actor", None)  # type: ignore[assignment]
            row.apply_actor = getattr(plan, "apply_actor", None)  # type: ignore[assignment]
            row.estimation_actor = getattr(plan, "estimation_actor", None)  # type: ignore[assignment]
            row.invariant_actor = getattr(plan, "invariant_actor", None)  # type: ignore[assignment]

            # Serialize automation profile — column is NOT NULL, default 'balanced'
            row.automation_profile = LifecyclePlanModel._serialize_automation_profile(  # type: ignore[assignment]
                plan.automation_profile,
            )
            row.effective_profile_snapshot = plan.effective_profile_snapshot  # type: ignore[assignment]

            row.error_message = plan.error_message  # type: ignore[assignment]
            row.error_details_json = (  # type: ignore[assignment]
                json.dumps(plan.error_details)
                if getattr(plan, "error_details", None) is not None
                else None
            )
            row.cost_estimate_usd = getattr(plan, "cost_estimate_usd", None)  # type: ignore[assignment]
            row.estimation_report_json = (  # type: ignore[assignment]
                json.dumps(getattr(plan, "estimation_report", None))
                if getattr(plan, "estimation_report", None) is not None
                else None
            )
            row.changeset_id = getattr(plan, "changeset_id", None)  # type: ignore[assignment]
            row.sandbox_refs_json = json.dumps(  # type: ignore[assignment]
                getattr(plan, "sandbox_refs", []) or []
            )
            row.validation_summary_json = (  # type: ignore[assignment]
                json.dumps(plan.validation_summary)
                if getattr(plan, "validation_summary", None) is not None
                else None
            )
            row.decision_root_id = getattr(plan, "decision_root_id", None)  # type: ignore[assignment]

            # Resume tracking fields
            row.reversion_count = plan.reversion_count  # type: ignore[assignment]
            row.last_completed_step = plan.last_completed_step  # type: ignore[assignment]
            row.last_checkpoint_id = plan.last_checkpoint_id  # type: ignore[assignment]

            row.reusable = plan.reusable  # type: ignore[assignment]
            row.read_only = plan.read_only  # type: ignore[assignment]
            row.execution_environment = plan.execution_environment  # type: ignore[assignment]
            row.execution_env_priority = (  # type: ignore[assignment]
                plan.execution_env_priority.value
                if plan.execution_env_priority is not None
                else None
            )
            row.created_by = plan.created_by  # type: ignore[assignment]
            row.tags_json = json.dumps(plan.tags)  # type: ignore[assignment]

            # Timestamps
            row.updated_at = plan.timestamps.updated_at.isoformat()  # type: ignore[assignment]
            row.completed_at = LifecyclePlanModel._to_iso(  # type: ignore[assignment]
                plan.timestamps.applied_at
            )
            row.strategize_started_at = LifecyclePlanModel._to_iso(  # type: ignore[assignment]
                plan.timestamps.strategize_started_at
            )
            row.strategize_completed_at = LifecyclePlanModel._to_iso(  # type: ignore[assignment]
                plan.timestamps.strategize_completed_at
            )
            row.execute_started_at = LifecyclePlanModel._to_iso(  # type: ignore[assignment]
                plan.timestamps.execute_started_at
            )
            row.execute_completed_at = LifecyclePlanModel._to_iso(  # type: ignore[assignment]
                plan.timestamps.execute_completed_at
            )
            row.apply_started_at = LifecyclePlanModel._to_iso(  # type: ignore[assignment]
                plan.timestamps.apply_started_at
            )
            row.applied_at = LifecyclePlanModel._to_iso(  # type: ignore[assignment]
                plan.timestamps.applied_at
            )

            # Replace child collections.  We must flush the DELETEs
            # from clear() before appending new rows, otherwise
            # SQLAlchemy may emit INSERTs before DELETEs, which
            # triggers UNIQUE constraint violations on composite keys
            # like (plan_id, name) in plan_arguments.
            row.project_links_rel.clear()  # type: ignore[union-attr]
            row.arguments_rel.clear()  # type: ignore[union-attr]
            row.invariants_rel.clear()  # type: ignore[union-attr]
            session.flush()
            now_iso = plan.timestamps.updated_at.isoformat()
            for pl in getattr(plan, "project_links", []) or []:
                row.project_links_rel.append(  # type: ignore[union-attr]
                    PlanProjectModel(
                        project_name=pl.project_name,
                        alias=pl.alias,
                        read_only=pl.read_only,
                        created_at=now_iso,
                    )
                )

            arguments_dict: dict[str, Any] = getattr(plan, "arguments", {}) or {}
            arguments_order: list[str] = getattr(plan, "arguments_order", []) or list(
                arguments_dict.keys()
            )
            for idx, arg_name in enumerate(arguments_order):
                if arg_name in arguments_dict:
                    row.arguments_rel.append(  # type: ignore[union-attr]
                        PlanArgumentModel(
                            name=arg_name,
                            value_json=json.dumps(arguments_dict[arg_name]),
                            value_type="string",
                            position=idx,
                        )
                    )

            for idx, inv in enumerate(getattr(plan, "invariants", []) or []):
                inv_text = inv.text if hasattr(inv, "text") else str(inv)
                raw_source: Any = inv.source if hasattr(inv, "source") else "plan"
                inv_source_str: str = (
                    raw_source.value
                    if hasattr(raw_source, "value")
                    else str(raw_source)
                )
                row.invariants_rel.append(  # type: ignore[union-attr]
                    PlanInvariantModel(
                        invariant_text=inv_text,
                        source_scope=inv_source_str,
                        source_name=None,
                        position=idx,
                        created_at=now_iso,
                    )
                )

            session.flush()
            return plan
        except PlanNotFoundError:
            raise
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            session.rollback()
            raise DatabaseError(f"Failed to update plan {plan_id_str}: {exc}") from exc

    @database_retry
    def list_all(self) -> list[Any]:
        """List all persisted lifecycle plans.

        Returns:
            List of ``Plan`` domain objects ordered by creation time
            (newest first).
        """
        session = self._session()
        try:
            rows = (
                session.query(LifecyclePlanModel)
                .order_by(LifecyclePlanModel.created_at.desc())
                .all()
            )
            return [r.to_domain() for r in rows]
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            raise DatabaseError(f"Failed to list plans: {exc}") from exc

    @database_retry
    def delete(self, plan_id: str) -> bool:
        """Delete a plan by ULID, cascading to child tables.

        Args:
            plan_id: The ULID string of the plan to delete.

        Returns:
            ``True`` if the plan was deleted, ``False`` if not found.

        Raises:
            DatabaseError: On transient or unexpected DB errors.
        """
        session = self._session()
        try:
            row = session.query(LifecyclePlanModel).filter_by(plan_id=plan_id).first()
            if row is None:
                return False
            session.delete(row)
            session.flush()
            return True
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            session.rollback()
            raise DatabaseError(f"Failed to delete plan {plan_id}: {exc}") from exc

    @database_retry
    def list_plans(
        self,
        phase: str | None = None,
        processing_state: str | None = None,
        action_name: str | None = None,
        project_name: str | None = None,
        namespace: str | None = None,
        limit: int = 100,
        offset: int = 0,
    ) -> list[Any]:
        """List plans with optional filters, ordered by created_at DESC.

        Args:
            phase: Optional phase filter (e.g., ``"action"``).
            processing_state: Optional state filter (e.g., ``"queued"``).
            action_name: Optional action name filter.
            project_name: Optional project name filter (joins plan_projects).
            namespace: Optional namespace filter.
            limit: Maximum number of results (default 100).
            offset: Number of results to skip (default 0).

        Returns:
            List of ``Plan`` domain objects.
        """
        session = self._session()
        try:
            query = session.query(LifecyclePlanModel)
            if phase is not None:
                query = query.filter(LifecyclePlanModel.phase == phase)
            if processing_state is not None:
                query = query.filter(
                    LifecyclePlanModel.processing_state == processing_state
                )
            if action_name is not None:
                query = query.filter(LifecyclePlanModel.action_name == action_name)
            if namespace is not None:
                query = query.filter(LifecyclePlanModel.namespace == namespace)
            if project_name is not None:
                query = query.join(
                    PlanProjectModel,
                    PlanProjectModel.plan_id == LifecyclePlanModel.plan_id,
                ).filter(PlanProjectModel.project_name == project_name)
            query = query.order_by(LifecyclePlanModel.created_at.desc())
            query = query.offset(offset).limit(limit)
            rows = query.all()
            return [row.to_domain() for row in rows]
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            raise DatabaseError(f"Failed to list plans: {exc}") from exc

    @database_retry
    def count(
        self,
        phase: str | None = None,
        processing_state: str | None = None,
    ) -> int:
        """Count matching plans with optional filters.

        Args:
            phase: Optional phase filter.
            processing_state: Optional state filter.

        Returns:
            Number of matching plans.
        """
        session = self._session()
        try:
            query = session.query(LifecyclePlanModel)
            if phase is not None:
                query = query.filter(LifecyclePlanModel.phase == phase)
            if processing_state is not None:
                query = query.filter(
                    LifecyclePlanModel.processing_state == processing_state
                )
            return cast(int, query.count())
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            raise DatabaseError(f"Failed to count plans: {exc}") from exc


# ---------------------------------------------------------------------------
# Resource Registry Repositories
# ---------------------------------------------------------------------------


class ResourceTypeNotFoundError(DatabaseError):
    """Raised when a resource type is not found."""

    def __init__(self, name: str):
        super().__init__(f"Resource type '{name}' not found")
        self.type_name = name


class ResourceNotFoundRepoError(DatabaseError):
    """Raised when a resource is not found by ID or name."""

    def __init__(self, identifier: str):
        super().__init__(f"Resource '{identifier}' not found")
        self.identifier = identifier


class ResourceHasEdgesError(BusinessRuleViolation):
    """Raised when deleting a resource that still has DAG edges."""

    def __init__(self, resource_id: str, edge_count: int):
        super().__init__(
            f"Cannot delete resource {resource_id}: "
            f"still has {edge_count} edge(s) in the resource DAG"
        )
        self.resource_id = resource_id
        self.edge_count = edge_count


class ResourceTypeHasResourcesError(BusinessRuleViolation):
    """Raised when deleting a resource type that still has resources."""

    def __init__(self, type_name: str, resource_count: int):
        super().__init__(
            f"Cannot delete resource type '{type_name}': "
            f"still referenced by {resource_count} resource(s)"
        )
        self.type_name = type_name
        self.resource_count = resource_count


class DuplicateResourceTypeError(DatabaseError):
    """Raised when creating a resource type with a name that already exists."""

    def __init__(self, name: str):
        super().__init__(f"Resource type with name '{name}' already exists")
        self.type_name = name


class DuplicateResourceError(DatabaseError):
    """Raised when creating a resource with a name that already exists."""

    def __init__(self, name: str):
        super().__init__(f"Resource with name '{name}' already exists")
        self.resource_name = name


class CycleDetectedError(BusinessRuleViolation):
    """Raised when a resource link would create a cycle."""

    def __init__(self, parent_id: str, child_id: str, path: list[str]):
        cycle_str = " -> ".join(path)
        super().__init__(
            f"Linking {parent_id} -> {child_id} would create a cycle: {cycle_str}"
        )
        self.parent_id = parent_id
        self.child_id = child_id
        self.path = path


class TypeIncompatibleError(BusinessRuleViolation):
    """Raised when a child resource type is not allowed by the parent type."""

    def __init__(self, parent_type: str, child_type: str):
        super().__init__(
            f"Type '{child_type}' is not an allowed child of '{parent_type}'"
        )
        self.parent_type = parent_type
        self.child_type = child_type


class LinkNotFoundError(DatabaseError):
    """Raised when a resource link cannot be found."""

    def __init__(self, parent_id: str, child_id: str):
        super().__init__(f"Link from '{parent_id}' to '{child_id}' not found")
        self.parent_id = parent_id
        self.child_id = child_id


class DuplicateResourceLinkError(DatabaseError):
    """Raised when creating a duplicate resource link."""

    def __init__(self, parent_id: str, child_id: str):
        super().__init__(f"Link from '{parent_id}' to '{child_id}' already exists")
        self.parent_id = parent_id
        self.child_id = child_id


class ResourceTypeRepository:
    """Repository for resource type persistence.

    Uses a session-factory pattern: each public method obtains its own
    session from the factory, ensuring proper session lifecycle management.

    All mutating methods flush (but do NOT commit); the caller or a
    ``UnitOfWork`` wrapper is responsible for committing the transaction.
    """

    def __init__(self, session_factory: Callable[[], Session]) -> None:
        """Initialise with a callable that returns a new SQLAlchemy Session."""
        self._session_factory = session_factory

    def _session(self) -> Session:
        """Convenience helper to obtain a session."""
        return self._session_factory()

    @database_retry
    def create(self, resource_type: Any) -> Any:
        """Persist a new resource type from a domain object.

        Args:
            resource_type: A ``ResourceTypeSpec`` domain model instance.

        Returns:
            The same resource type after persistence.

        Raises:
            DuplicateResourceTypeError: If a type with the same name exists.
            DatabaseError: On transient or unexpected DB errors.
        """
        session = self._session()
        try:
            # Check for existing type to prevent retry-induced duplicates
            existing = (
                session.query(ResourceTypeModel)
                .filter_by(name=resource_type.name)
                .first()
            )
            if existing is not None:
                raise DuplicateResourceTypeError(resource_type.name)

            now_iso = datetime.now(tz=UTC).isoformat()
            db_model = ResourceTypeModel(
                name=resource_type.name,
                namespace=resource_type.name.split("/")[0]
                if "/" in resource_type.name
                else "builtin",
                description=resource_type.description or None,
                resource_kind=resource_type.resource_kind.value
                if hasattr(resource_type.resource_kind, "value")
                else resource_type.resource_kind,
                sandbox_strategy=resource_type.sandbox_strategy.value
                if hasattr(resource_type.sandbox_strategy, "value")
                else resource_type.sandbox_strategy,
                user_addable=resource_type.user_addable,
                handler_ref=resource_type.handler,
                args_schema_json=json.dumps(
                    [
                        {
                            "name": arg.name,
                            "type": arg.type,
                            "required": arg.required,
                            "description": arg.description,
                            "default": arg.default,
                            "validation_pattern": arg.validation_pattern,
                        }
                        for arg in resource_type.cli_args
                    ]
                )
                if resource_type.cli_args
                else None,
                allowed_parent_types_json=json.dumps(resource_type.parent_types)
                if resource_type.parent_types
                else None,
                allowed_child_types_json=json.dumps(resource_type.child_types)
                if resource_type.child_types
                else None,
                auto_discover_json=json.dumps(resource_type.auto_discovery)
                if resource_type.auto_discovery
                else None,
                capabilities_json=json.dumps(resource_type.capabilities)
                if resource_type.capabilities
                else None,
                equivalence_json=json.dumps(resource_type.equivalence)
                if resource_type.equivalence
                else None,
                source=getattr(resource_type, "source", None),
                created_at=now_iso,
                updated_at=now_iso,
            )
            session.add(db_model)
            session.flush()
            return resource_type
        except IntegrityError as exc:
            session.rollback()
            if "UNIQUE" in str(exc).upper() or "unique" in str(exc).lower():
                raise DuplicateResourceTypeError(resource_type.name) from exc
            raise DatabaseError(f"Failed to create resource type: {exc}") from exc
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            session.rollback()
            raise DatabaseError(f"Failed to create resource type: {exc}") from exc

    @database_retry
    def get(self, name: str) -> Any | None:
        """Get a resource type by its name (PK).

        Returns:
            A ``ResourceTypeSpec`` domain object, or ``None`` if not found.
        """
        session = self._session()
        try:
            row = session.query(ResourceTypeModel).filter_by(name=name).first()
            if row is None:
                return None
            return self._to_domain(row)
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            raise DatabaseError(f"Failed to get resource type '{name}': {exc}") from exc

    @database_retry
    def list_types(
        self,
        namespace: str | None = None,
        user_addable: bool | None = None,
    ) -> list[Any]:
        """List resource types with optional filters.

        Args:
            namespace: Optional namespace to filter by.
            user_addable: Optional boolean to filter user-addable types.

        Returns:
            List of ``ResourceTypeSpec`` domain objects, ordered by name.
        """
        session = self._session()
        try:
            query = session.query(ResourceTypeModel)
            if namespace is not None:
                query = query.filter_by(namespace=namespace)
            if user_addable is not None:
                query = query.filter_by(user_addable=user_addable)
            rows = query.order_by(ResourceTypeModel.name).all()
            return [self._to_domain(row) for row in rows]
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            raise DatabaseError(f"Failed to list resource types: {exc}") from exc

    @database_retry
    def update(self, resource_type: Any) -> Any:
        """Update mutable fields of an existing resource type.

        Args:
            resource_type: The ``ResourceTypeSpec`` domain model with updated fields.

        Returns:
            The same resource type after persistence.

        Raises:
            ResourceTypeNotFoundError: If the resource type is not found.
            DatabaseError: On transient or unexpected DB errors.
        """
        session = self._session()
        try:
            row = (
                session.query(ResourceTypeModel)
                .filter_by(name=resource_type.name)
                .first()
            )
            if row is None:
                raise ResourceTypeNotFoundError(resource_type.name)

            row.description = resource_type.description or None  # type: ignore[assignment]
            row.resource_kind = (  # type: ignore[assignment]
                resource_type.resource_kind.value
                if hasattr(resource_type.resource_kind, "value")
                else resource_type.resource_kind
            )
            row.sandbox_strategy = (  # type: ignore[assignment]
                resource_type.sandbox_strategy.value
                if hasattr(resource_type.sandbox_strategy, "value")
                else resource_type.sandbox_strategy
            )
            row.user_addable = resource_type.user_addable  # type: ignore[assignment]
            row.handler_ref = resource_type.handler  # type: ignore[assignment]
            row.args_schema_json = (  # type: ignore[assignment]
                json.dumps(
                    [
                        {
                            "name": arg.name,
                            "type": arg.type,
                            "required": arg.required,
                            "description": arg.description,
                            "default": arg.default,
                            "validation_pattern": arg.validation_pattern,
                        }
                        for arg in resource_type.cli_args
                    ]
                )
                if resource_type.cli_args
                else None
            )
            row.allowed_parent_types_json = (  # type: ignore[assignment]
                json.dumps(resource_type.parent_types)
                if resource_type.parent_types
                else None
            )
            row.allowed_child_types_json = (  # type: ignore[assignment]
                json.dumps(resource_type.child_types)
                if resource_type.child_types
                else None
            )
            row.auto_discover_json = (  # type: ignore[assignment]
                json.dumps(resource_type.auto_discovery)
                if resource_type.auto_discovery
                else None
            )
            row.capabilities_json = (  # type: ignore[assignment]
                json.dumps(resource_type.capabilities)
                if resource_type.capabilities
                else None
            )
            row.equivalence_json = (  # type: ignore[assignment]
                json.dumps(resource_type.equivalence)
                if resource_type.equivalence
                else None
            )
            row.updated_at = datetime.now(tz=UTC).isoformat()  # type: ignore[assignment]
            session.flush()
            return resource_type
        except ResourceTypeNotFoundError:
            raise
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            session.rollback()
            raise DatabaseError(
                f"Failed to update resource type '{resource_type.name}': {exc}"
            ) from exc

    @database_retry
    def delete(self, name: str) -> bool:
        """Delete a resource type by name.

        Before deletion, verifies no resources reference this type.

        Args:
            name: The resource type name to delete.

        Returns:
            ``True`` if the resource type was deleted, ``False`` if not found.

        Raises:
            ResourceTypeHasResourcesError: If resources still reference this type.
            DatabaseError: On transient or unexpected DB errors.
        """
        session = self._session()
        try:
            resource_count: int = (
                session.query(ResourceModel)
                .filter(ResourceModel.type_name == name)
                .count()
            )
            if resource_count > 0:
                raise ResourceTypeHasResourcesError(name, resource_count)

            row = session.query(ResourceTypeModel).filter_by(name=name).first()
            if row is None:
                return False
            session.delete(row)
            session.flush()
            return True
        except ResourceTypeHasResourcesError:
            raise
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            session.rollback()
            raise DatabaseError(
                f"Failed to delete resource type '{name}': {exc}"
            ) from exc

    @staticmethod
    def _to_domain(row: ResourceTypeModel) -> Any:
        """Convert a ``ResourceTypeModel`` row to a ``ResourceTypeSpec``."""
        from cleveragents.domain.models.core.resource_type import (
            ResourceKind,
            ResourceTypeArgument,
            ResourceTypeSpec,
            SandboxStrategy,
        )

        cli_args: list[ResourceTypeArgument] = []
        raw_args = cast("str | None", row.args_schema_json)
        if raw_args:
            for arg_data in json.loads(raw_args):
                cli_args.append(
                    ResourceTypeArgument(
                        name=arg_data["name"],
                        type=arg_data.get("type", "string"),
                        required=arg_data.get("required", True),
                        description=arg_data.get("description", ""),
                        default=arg_data.get("default"),
                        validation_pattern=arg_data.get("validation_pattern"),
                    )
                )

        parent_types: list[str] = json.loads(
            cast(str, row.allowed_parent_types_json or "[]")
        )
        child_types: list[str] = json.loads(
            cast(str, row.allowed_child_types_json or "[]")
        )
        auto_discovery: dict[str, Any] | None = None
        if row.auto_discover_json is not None:
            auto_discovery = json.loads(cast(str, row.auto_discover_json))
        equivalence: dict[str, Any] | None = None
        if row.equivalence_json is not None:
            equivalence = json.loads(cast(str, row.equivalence_json))
        capabilities: dict[str, bool] = json.loads(
            cast(
                str,
                row.capabilities_json
                or '{"read": true, "write": true, "sandbox": true,'
                ' "checkpoint": false}',
            )
        )

        name_str = cast(str, row.name)
        is_builtin = "/" not in name_str

        return ResourceTypeSpec(
            name=name_str,
            description=cast(str, row.description or ""),
            resource_kind=ResourceKind(cast(str, row.resource_kind)),
            sandbox_strategy=SandboxStrategy(cast(str, row.sandbox_strategy)),
            user_addable=cast(bool, row.user_addable),
            cli_args=cli_args,
            parent_types=parent_types,
            child_types=child_types,
            auto_discovery=auto_discovery,
            equivalence=equivalence,
            handler=cast("str | None", row.handler_ref),
            capabilities=capabilities,
            built_in=is_builtin,
        )


class ResourceRepository:
    """Repository for resource persistence.

    Uses a session-factory pattern: each public method obtains its own
    session from the factory, ensuring proper session lifecycle management.

    All mutating methods flush (but do NOT commit); the caller or a
    ``UnitOfWork`` wrapper is responsible for committing the transaction.
    """

    def __init__(self, session_factory: Callable[[], Session]) -> None:
        """Initialise with a callable that returns a new SQLAlchemy Session."""
        self._session_factory = session_factory

    def _session(self) -> Session:
        """Convenience helper to obtain a session."""
        return self._session_factory()

    @database_retry
    def create(self, resource: Any) -> Any:
        """Persist a new resource.

        Args:
            resource: A ``Resource`` domain model instance.

        Returns:
            The same resource after persistence.

        Raises:
            DuplicateResourceError: If a resource with the same name exists.
            ResourceTypeNotFoundError: If the referenced type doesn't exist.
            DatabaseError: On transient or unexpected DB errors.
        """
        session = self._session()
        try:
            # Validate that the referenced type exists
            type_row = (
                session.query(ResourceTypeModel)
                .filter_by(name=resource.resource_type_name)
                .first()
            )
            if type_row is None:
                raise ResourceTypeNotFoundError(resource.resource_type_name)

            # Check for existing resource with the same name
            if resource.name is not None:
                existing = (
                    session.query(ResourceModel)
                    .filter_by(namespaced_name=resource.name)
                    .first()
                )
                if existing is not None:
                    raise DuplicateResourceError(resource.name)

            now_iso = datetime.now(tz=UTC).isoformat()
            db_model = ResourceModel(
                resource_id=resource.resource_id,
                namespaced_name=resource.name,
                namespace=resource.name.split("/")[0]
                if resource.name and "/" in resource.name
                else None,
                type_name=resource.resource_type_name,
                resource_kind=resource.classification,
                location=resource.location,
                description=resource.description,
                read_only=not resource.capabilities.writable
                if hasattr(resource, "capabilities")
                else False,
                auto_discovered=False,
                sandbox_strategy=resource.sandbox_strategy,
                content_hash=resource.content_hash,
                properties_json=json.dumps(resource.properties)
                if resource.properties
                else None,
                metadata_json=None,
                created_at=now_iso,
                updated_at=now_iso,
            )
            session.add(db_model)
            session.flush()
            return resource
        except (ResourceTypeNotFoundError, DuplicateResourceError):
            raise
        except IntegrityError as exc:
            session.rollback()
            if "UNIQUE" in str(exc).upper() or "unique" in str(exc).lower():
                raise DuplicateResourceError(
                    resource.name or resource.resource_id
                ) from exc
            raise DatabaseError(f"Failed to create resource: {exc}") from exc
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            session.rollback()
            raise DatabaseError(f"Failed to create resource: {exc}") from exc

    @database_retry
    def get(self, resource_id: str) -> Any | None:
        """Get a resource by its ULID primary key.

        Returns:
            A ``Resource`` domain object, or ``None`` if not found.
        """
        session = self._session()
        try:
            row = (
                session.query(ResourceModel).filter_by(resource_id=resource_id).first()
            )
            if row is None:
                return None
            return self._to_domain(row)
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            raise DatabaseError(
                f"Failed to get resource '{resource_id}': {exc}"
            ) from exc

    @database_retry
    def get_by_name(self, namespaced_name: str) -> Any | None:
        """Get a resource by its namespaced name.

        Returns:
            A ``Resource`` domain object, or ``None`` if not found.
        """
        session = self._session()
        try:
            row = (
                session.query(ResourceModel)
                .filter_by(namespaced_name=namespaced_name)
                .first()
            )
            if row is None:
                return None
            return self._to_domain(row)
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            raise DatabaseError(
                f"Failed to get resource by name '{namespaced_name}': {exc}"
            ) from exc

    @database_retry
    def list_resources(
        self,
        type_name: str | None = None,
        namespace: str | None = None,
        limit: int = 100,
        offset: int = 0,
    ) -> list[Any]:
        """List resources with optional filters and pagination.

        Args:
            type_name: Optional resource type name filter.
            namespace: Optional namespace filter.
            limit: Maximum number of results (default 100).
            offset: Number of results to skip (default 0).

        Returns:
            List of ``Resource`` domain objects.
        """
        session = self._session()
        try:
            query = session.query(ResourceModel)
            if type_name is not None:
                query = query.filter_by(type_name=type_name)
            if namespace is not None:
                query = query.filter_by(namespace=namespace)
            rows = (
                query.order_by(ResourceModel.created_at)
                .limit(limit)
                .offset(offset)
                .all()
            )
            return [self._to_domain(row) for row in rows]
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            raise DatabaseError(f"Failed to list resources: {exc}") from exc

    @database_retry
    def update(self, resource: Any) -> Any:
        """Update metadata of an existing resource.

        Args:
            resource: The ``Resource`` domain model with updated fields.

        Returns:
            The same resource after persistence.

        Raises:
            ResourceNotFoundRepoError: If the resource is not found.
            DatabaseError: On transient or unexpected DB errors.
        """
        session = self._session()
        try:
            row = (
                session.query(ResourceModel)
                .filter_by(resource_id=resource.resource_id)
                .first()
            )
            if row is None:
                raise ResourceNotFoundRepoError(resource.resource_id)

            row.namespaced_name = resource.name  # type: ignore[assignment]
            row.namespace = (  # type: ignore[assignment]
                resource.name.split("/")[0]
                if resource.name and "/" in resource.name
                else None
            )
            row.description = resource.description  # type: ignore[assignment]
            row.location = resource.location  # type: ignore[assignment]
            row.read_only = (  # type: ignore[assignment]
                not resource.capabilities.writable
                if hasattr(resource, "capabilities")
                else False
            )
            row.sandbox_strategy = resource.sandbox_strategy  # type: ignore[assignment]
            row.content_hash = resource.content_hash  # type: ignore[assignment]
            row.properties_json = (  # type: ignore[assignment]
                json.dumps(resource.properties) if resource.properties else None
            )
            row.updated_at = datetime.now(tz=UTC).isoformat()  # type: ignore[assignment]
            session.flush()
            return resource
        except ResourceNotFoundRepoError:
            raise
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            session.rollback()
            raise DatabaseError(
                f"Failed to update resource '{resource.resource_id}': {exc}"
            ) from exc

    @database_retry
    def delete(self, resource_id: str) -> bool:
        """Delete a resource by its ULID.

        Before deletion, verifies no edges reference this resource.

        Args:
            resource_id: The ULID of the resource to delete.

        Returns:
            ``True`` if the resource was deleted, ``False`` if not found.

        Raises:
            ResourceHasEdgesError: If edges still reference this resource.
            DatabaseError: On transient or unexpected DB errors.
        """
        session = self._session()
        try:
            edge_count: int = (
                session.query(ResourceEdgeModel)
                .filter(
                    (ResourceEdgeModel.parent_id == resource_id)
                    | (ResourceEdgeModel.child_id == resource_id)
                )
                .count()
            )
            if edge_count > 0:
                raise ResourceHasEdgesError(resource_id, edge_count)

            row = (
                session.query(ResourceModel).filter_by(resource_id=resource_id).first()
            )
            if row is None:
                return False
            session.delete(row)
            session.flush()
            return True
        except ResourceHasEdgesError:
            raise
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            session.rollback()
            raise DatabaseError(
                f"Failed to delete resource '{resource_id}': {exc}"
            ) from exc

    @database_retry
    def link_child(self, parent_id: str, child_id: str) -> None:
        """Link a child resource to a parent in the DAG.

        Validates both resources exist, checks type compatibility
        (child's type must be in parent type's ``child_types``),
        and detects cycles before persisting.

        Args:
            parent_id: ULID of the parent resource.
            child_id: ULID of the child resource.

        Raises:
            ResourceNotFoundRepoError: If either resource is missing.
            TypeIncompatibleError: If child type is not allowed.
            CycleDetectedError: If the link would create a cycle.
            DuplicateResourceLinkError: If the link already exists.
            DatabaseError: On transient or unexpected DB errors.
        """
        if parent_id == child_id:
            raise CycleDetectedError(parent_id, child_id, [parent_id, child_id])

        session = self._session()
        try:
            parent_row = (
                session.query(ResourceModel).filter_by(resource_id=parent_id).first()
            )
            if parent_row is None:
                raise ResourceNotFoundRepoError(parent_id)

            child_row = (
                session.query(ResourceModel).filter_by(resource_id=child_id).first()
            )
            if child_row is None:
                raise ResourceNotFoundRepoError(child_id)

            # Check type compatibility
            parent_type_name = cast(str, parent_row.type_name)
            child_type_name = cast(str, child_row.type_name)

            parent_type_row = (
                session.query(ResourceTypeModel)
                .filter_by(name=parent_type_name)
                .first()
            )
            if parent_type_row is not None:
                allowed_raw = cast(
                    "str | None",
                    parent_type_row.allowed_child_types_json,
                )
                allowed_children: list[str] = (
                    json.loads(allowed_raw) if allowed_raw else []
                )
                if allowed_children and child_type_name not in allowed_children:
                    raise TypeIncompatibleError(parent_type_name, child_type_name)

            # Check for duplicate link
            existing = (
                session.query(ResourceLinkModel)
                .filter_by(parent_id=parent_id, child_id=child_id)
                .first()
            )
            if existing is not None:
                raise DuplicateResourceLinkError(parent_id, child_id)

            # Cycle detection: ensure child_id is not an
            # ancestor of parent_id
            ancestors = self._get_ancestors(session, parent_id)
            if child_id in ancestors:
                cycle_path = self._build_cycle_path(session, parent_id, child_id)
                raise CycleDetectedError(parent_id, child_id, cycle_path)

            link = ResourceLinkModel(
                parent_id=parent_id,
                child_id=child_id,
                created_at=datetime.now(tz=UTC).isoformat(),
            )
            session.add(link)
            session.flush()
        except (
            ResourceNotFoundRepoError,
            TypeIncompatibleError,
            CycleDetectedError,
            DuplicateResourceLinkError,
        ):
            raise
        except IntegrityError as exc:
            session.rollback()
            raise DatabaseError(
                f"Failed to link {parent_id} -> {child_id}: {exc}"
            ) from exc
        except (
            OperationalError,
            SQLAlchemyDatabaseError,
        ) as exc:
            session.rollback()
            raise DatabaseError(
                f"Failed to link {parent_id} -> {child_id}: {exc}"
            ) from exc

    @database_retry
    def unlink_child(self, parent_id: str, child_id: str) -> None:
        """Remove a parent-child link from the DAG.

        Args:
            parent_id: ULID of the parent resource.
            child_id: ULID of the child resource.

        Raises:
            ResourceNotFoundRepoError: If either resource missing.
            LinkNotFoundError: If the link does not exist.
            DatabaseError: On transient or unexpected DB errors.
        """
        session = self._session()
        try:
            parent_row = (
                session.query(ResourceModel).filter_by(resource_id=parent_id).first()
            )
            if parent_row is None:
                raise ResourceNotFoundRepoError(parent_id)

            child_row = (
                session.query(ResourceModel).filter_by(resource_id=child_id).first()
            )
            if child_row is None:
                raise ResourceNotFoundRepoError(child_id)

            link = (
                session.query(ResourceLinkModel)
                .filter_by(parent_id=parent_id, child_id=child_id)
                .first()
            )
            if link is None:
                raise LinkNotFoundError(parent_id, child_id)

            session.delete(link)
            session.flush()
        except (
            ResourceNotFoundRepoError,
            LinkNotFoundError,
        ):
            raise
        except (
            OperationalError,
            SQLAlchemyDatabaseError,
        ) as exc:
            session.rollback()
            raise DatabaseError(
                f"Failed to unlink {parent_id} -> {child_id}: {exc}"
            ) from exc

    @database_retry
    def get_children(self, resource_id: str) -> list[Any]:
        """Get all direct children of a resource.

        Args:
            resource_id: ULID of the parent resource.

        Returns:
            List of child ``Resource`` domain objects.
        """
        session = self._session()
        try:
            links = (
                session.query(ResourceLinkModel).filter_by(parent_id=resource_id).all()
            )
            children: list[Any] = []
            for link in links:
                child_row = (
                    session.query(ResourceModel)
                    .filter_by(resource_id=cast(str, link.child_id))
                    .first()
                )
                if child_row is not None:
                    children.append(self._to_domain(child_row))
            return children
        except (
            OperationalError,
            SQLAlchemyDatabaseError,
        ) as exc:
            raise DatabaseError(
                f"Failed to get children of '{resource_id}': {exc}"
            ) from exc

    @database_retry
    def get_parents(self, resource_id: str) -> list[Any]:
        """Get all direct parents of a resource.

        Args:
            resource_id: ULID of the child resource.

        Returns:
            List of parent ``Resource`` domain objects.
        """
        session = self._session()
        try:
            links = (
                session.query(ResourceLinkModel).filter_by(child_id=resource_id).all()
            )
            parents: list[Any] = []
            for link in links:
                parent_row = (
                    session.query(ResourceModel)
                    .filter_by(resource_id=cast(str, link.parent_id))
                    .first()
                )
                if parent_row is not None:
                    parents.append(self._to_domain(parent_row))
            return parents
        except (
            OperationalError,
            SQLAlchemyDatabaseError,
        ) as exc:
            raise DatabaseError(
                f"Failed to get parents of '{resource_id}': {exc}"
            ) from exc

    @database_retry
    def auto_discover_children(self, resource_id: str) -> list[Any]:
        """Materialize child resources per type auto-discovery.

        Looks up the resource's type, checks auto_discovery config,
        and for each child type with auto-discover enabled, creates
        a child resource and links it to the parent.

        Args:
            resource_id: ULID of the parent resource.

        Returns:
            List of newly created child ``Resource`` domain objects.

        Raises:
            ResourceNotFoundRepoError: If the resource is missing.
            DatabaseError: On transient or unexpected DB errors.
        """
        from ulid import ULID as _ULID

        from cleveragents.domain.models.core.resource import (
            PhysVirt,
            Resource,
            ResourceCapabilities,
        )

        session = self._session()
        try:
            parent_row = (
                session.query(ResourceModel).filter_by(resource_id=resource_id).first()
            )
            if parent_row is None:
                raise ResourceNotFoundRepoError(resource_id)

            parent_type_name = cast(str, parent_row.type_name)
            type_row = (
                session.query(ResourceTypeModel)
                .filter_by(name=parent_type_name)
                .first()
            )
            if type_row is None:
                return []

            # Parse auto_discovery config
            auto_disc_raw = cast("str | None", type_row.auto_discover_json)
            if not auto_disc_raw:
                return []

            auto_disc: dict[str, Any] = json.loads(auto_disc_raw)
            if not auto_disc.get("enabled", False):
                return []

            rules: list[dict[str, Any]] = auto_disc.get("rules", [])
            if not rules:
                return []

            # Parse allowed child types
            child_types_raw = cast(
                "str | None",
                type_row.allowed_child_types_json,
            )
            allowed_child_types: list[str] = (
                json.loads(child_types_raw) if child_types_raw else []
            )

            created: list[Any] = []
            for rule in rules:
                child_type_name = rule.get("type", "")
                if not child_type_name:
                    continue

                # Verify child type exists in DB
                ct_row = (
                    session.query(ResourceTypeModel)
                    .filter_by(name=child_type_name)
                    .first()
                )
                if ct_row is None:
                    continue

                # Verify type compatibility
                if allowed_child_types and child_type_name not in allowed_child_types:
                    continue

                ct_kind = cast(str, ct_row.resource_kind)

                now_iso = datetime.now(tz=UTC).isoformat()
                child_id = str(_ULID())
                child_model = ResourceModel(
                    resource_id=child_id,
                    namespaced_name=None,
                    namespace=None,
                    type_name=child_type_name,
                    resource_kind=ct_kind,
                    location=None,
                    description=(f"Auto-discovered {child_type_name}"),
                    read_only=False,
                    auto_discovered=True,
                    sandbox_strategy=None,
                    content_hash=None,
                    properties_json=None,
                    metadata_json=None,
                    created_at=now_iso,
                    updated_at=now_iso,
                )
                session.add(child_model)
                session.flush()

                # Link child to parent
                link = ResourceLinkModel(
                    parent_id=resource_id,
                    child_id=child_id,
                    created_at=now_iso,
                )
                session.add(link)
                session.flush()

                child_resource = Resource(
                    resource_id=child_id,
                    name=None,
                    resource_type_name=child_type_name,
                    classification=PhysVirt(ct_kind),
                    description=(f"Auto-discovered {child_type_name}"),
                    properties={},
                    location=None,
                    content_hash=None,
                    sandbox_strategy=None,
                    capabilities=ResourceCapabilities(),
                    created_at=datetime.fromisoformat(now_iso),
                    updated_at=datetime.fromisoformat(now_iso),
                )
                created.append(child_resource)

            return created
        except ResourceNotFoundRepoError:
            raise
        except (
            OperationalError,
            SQLAlchemyDatabaseError,
        ) as exc:
            session.rollback()
            raise DatabaseError(
                f"Failed to auto-discover children for '{resource_id}': {exc}"
            ) from exc

    @staticmethod
    def _get_ancestors(session: Session, resource_id: str) -> set[str]:
        """Return all ancestor resource IDs (BFS upward).

        Used for cycle detection: if a proposed child is
        already an ancestor of the parent, linking would
        create a cycle.
        """
        visited: set[str] = set()
        queue: list[str] = [resource_id]
        while queue:
            current = queue.pop(0)
            if current in visited:
                continue
            visited.add(current)
            parent_links = (
                session.query(ResourceLinkModel).filter_by(child_id=current).all()
            )
            for link in parent_links:
                pid = cast(str, link.parent_id)
                if pid not in visited:
                    queue.append(pid)
        return visited

    @staticmethod
    def _build_cycle_path(
        session: Session,
        parent_id: str,
        child_id: str,
    ) -> list[str]:
        """Build a path showing the cycle for error msgs.

        Returns a list like [child_id, ..., parent_id,
        child_id] showing the cycle.
        """
        # BFS from child_id upward to find parent_id
        predecessors: dict[str, str | None] = {parent_id: None}
        queue: list[str] = [parent_id]
        found = False
        while queue and not found:
            current = queue.pop(0)
            parent_links = (
                session.query(ResourceLinkModel).filter_by(child_id=current).all()
            )
            for link in parent_links:
                pid = cast(str, link.parent_id)
                if pid not in predecessors:
                    predecessors[pid] = current
                    if pid == child_id:
                        found = True
                        break
                    queue.append(pid)

        # Reconstruct path
        path: list[str] = []
        current_node: str | None = child_id
        while current_node is not None:
            path.append(current_node)
            current_node = predecessors.get(current_node)
        path.append(child_id)
        return path

    @database_retry
    def resolve_namespaced_name(self, name_or_id: str) -> Any | None:
        """Resolve a resource by namespaced name first, then try ULID.

        This helper tries name lookup first (most common case),
        falling back to ULID-based lookup.

        Args:
            name_or_id: Either a namespaced name or a ULID string.

        Returns:
            A ``Resource`` domain object, or ``None`` if not found.
        """
        import re

        session = self._session()
        try:
            # Try by name first
            row = (
                session.query(ResourceModel)
                .filter_by(namespaced_name=name_or_id)
                .first()
            )
            if row is not None:
                return self._to_domain(row)

            # Try by ULID (26 chars, Crockford base32)
            ulid_pattern = re.compile(r"^[0-9A-HJKMNP-TV-Z]{26}$")
            if ulid_pattern.match(name_or_id):
                row = (
                    session.query(ResourceModel)
                    .filter_by(resource_id=name_or_id)
                    .first()
                )
                if row is not None:
                    return self._to_domain(row)

            return None
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            raise DatabaseError(
                f"Failed to resolve resource '{name_or_id}': {exc}"
            ) from exc

    @staticmethod
    def _to_domain(row: ResourceModel) -> Any:
        """Convert a ``ResourceModel`` row to a ``Resource`` domain object."""
        from cleveragents.domain.models.core.resource import (
            PhysVirt,
            Resource,
            ResourceCapabilities,
            SandboxStrategy,
        )

        properties: dict[str, str | int | float | bool | None] = {}
        raw_props = cast("str | None", row.properties_json)
        if raw_props:
            properties = json.loads(raw_props)

        sandbox_strat: SandboxStrategy | None = None
        if row.sandbox_strategy is not None:
            sandbox_strat = SandboxStrategy(cast(str, row.sandbox_strategy))

        return Resource(
            resource_id=cast(str, row.resource_id),
            name=cast("str | None", row.namespaced_name),
            resource_type_name=cast(str, row.type_name),
            classification=PhysVirt(cast(str, row.resource_kind)),
            description=cast("str | None", row.description),
            properties=properties,
            location=cast("str | None", row.location),
            content_hash=cast("str | None", row.content_hash),
            sandbox_strategy=sandbox_strat,
            capabilities=ResourceCapabilities(
                readable=True,
                writable=not cast(bool, row.read_only),
                sandboxable=True,
                checkpointable=False,
            ),
            created_at=datetime.fromisoformat(cast(str, row.created_at)),
            updated_at=datetime.fromisoformat(cast(str, row.updated_at)),
        )


# ---------------------------------------------------------------------------
# Project Not Found Error
# ---------------------------------------------------------------------------


class ProjectNotFoundError(DatabaseError):
    """Raised when a project lookup by namespaced name fails."""

    def __init__(self, namespaced_name: str):
        super().__init__(f"Project '{namespaced_name}' not found")
        self.namespaced_name = namespaced_name


# ---------------------------------------------------------------------------
# Duplicate Link Error
# ---------------------------------------------------------------------------


class DuplicateLinkError(DatabaseError):
    """Raised when creating a duplicate alias within a project."""

    def __init__(self, project_name: str, alias: str):
        super().__init__(f"Alias '{alias}' already exists in project '{project_name}'")
        self.project_name = project_name
        self.alias = alias


# ---------------------------------------------------------------------------
# Namespaced Project Repository
# ---------------------------------------------------------------------------


class NamespacedProjectRepository(ProjectRepositoryProtocol):
    """Repository for spec-aligned namespaced project persistence.

    Implements :class:`~cleveragents.domain.repositories.ProjectRepositoryProtocol`
    — the domain-layer port for namespaced project persistence.

    Uses a session-factory pattern: each public method obtains its own
    session from the factory, ensuring proper session lifecycle management.

    All mutating methods (``create``, ``update``, ``delete``) flush **and
    commit** within their own session.  No external ``UnitOfWork`` is needed.
    """

    def __init__(self, session_factory: Callable[[], Session]) -> None:
        """Initialise with a callable that returns a new SQLAlchemy Session."""
        self._session_factory = session_factory

    def _session(self) -> Session:
        """Convenience helper to obtain a session."""
        return self._session_factory()

    @database_retry
    def create(self, project: Any) -> Any:
        """Persist a new ``NamespacedProject`` domain object.

        Args:
            project: A ``NamespacedProject`` domain model instance.

        Returns:
            The same ``NamespacedProject`` after persistence.

        Raises:
            DatabaseError: If the project already exists or a DB error occurs.
        """
        session = self._session()
        try:
            db_model = NamespacedProjectModel.from_domain(project)
            session.add(db_model)
            session.commit()
            return project
        except IntegrityError as exc:
            session.rollback()
            if "UNIQUE" in str(exc).upper() or "unique" in str(exc).lower():
                raise DatabaseError(
                    f"Project '{project.namespaced_name}' already exists"
                ) from exc
            raise DatabaseError(f"Failed to create project: {exc}") from exc
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            session.rollback()
            raise DatabaseError(f"Failed to create project: {exc}") from exc
        finally:
            session.close()

    @database_retry
    def get(self, namespaced_name: str) -> Any:
        """Retrieve a project by its namespaced name (PK).

        Args:
            namespaced_name: The ``namespace/name`` string.

        Returns:
            The ``NamespacedProject`` domain object.

        Raises:
            ProjectNotFoundError: If the project is not found.
        """
        session = self._session()
        try:
            row = (
                session.query(NamespacedProjectModel)
                .filter_by(namespaced_name=namespaced_name)
                .first()
            )
            if row is None:
                raise ProjectNotFoundError(namespaced_name)
            return row.to_domain()
        except ProjectNotFoundError:
            raise
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            raise DatabaseError(
                f"Failed to get project '{namespaced_name}': {exc}"
            ) from exc

    @database_retry
    def list_projects(
        self,
        namespace: str | None = None,
        limit: int = 100,
    ) -> list[Any]:
        """List projects, optionally filtered by namespace.

        Args:
            namespace: Optional namespace filter.
            limit: Maximum number of results (default 100).

        Returns:
            List of ``NamespacedProject`` domain objects.
        """
        session = self._session()
        try:
            query = session.query(NamespacedProjectModel)
            if namespace is not None:
                query = query.filter_by(namespace=namespace)
            query = query.order_by(NamespacedProjectModel.namespaced_name)
            rows = query.limit(limit).all()
            return [row.to_domain() for row in rows]
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            raise DatabaseError(f"Failed to list projects: {exc}") from exc

    @database_retry
    def get_context_policy(self, namespaced_name: str) -> Any:
        """Return a project's stored ``ProjectContextPolicy`` or the default."""
        from cleveragents.domain.models.core.context_policy import ProjectContextPolicy

        session = self._session()
        try:
            row = (
                session.query(NamespacedProjectModel)
                .filter_by(namespaced_name=namespaced_name)
                .first()
            )
            if row is None or row.context_policy_json is None:
                return ProjectContextPolicy()

            payload = json.loads(cast(str, row.context_policy_json))
            if not isinstance(payload, dict):
                _log.warning(
                    "project_context_policy_payload_invalid",
                    project_name=namespaced_name,
                )
                return ProjectContextPolicy()
            return ProjectContextPolicy.model_validate(payload)
        except (ValueError, TypeError):
            _log.warning(
                "project_context_policy_invalid",
                project_name=namespaced_name,
                exc_info=True,
            )
            return ProjectContextPolicy()
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            raise DatabaseError(
                f"Failed to load project context policy for '{namespaced_name}': {exc}"
            ) from exc
        finally:
            session.close()

    @database_retry
    def update(self, project: Any) -> Any:
        """Update mutable fields of an existing project.

        Args:
            project: The ``NamespacedProject`` domain model with updated fields.

        Returns:
            The same ``NamespacedProject`` after persistence.

        Raises:
            ProjectNotFoundError: If the project is not found.
            DatabaseError: On transient or unexpected DB errors.
        """
        import json as _json

        session = self._session()
        ns_name = project.namespaced_name
        try:
            row = (
                session.query(NamespacedProjectModel)
                .filter_by(namespaced_name=ns_name)
                .first()
            )
            if row is None:
                raise ProjectNotFoundError(ns_name)

            row.namespace = project.namespace  # type: ignore[assignment]
            row.description = project.description  # type: ignore[assignment]

            # Serialize context_config
            context_policy_json: str | None = None
            if project.context_config is not None:
                config_dict = project.context_config.model_dump(mode="json")
                context_policy_json = _json.dumps(config_dict)
            row.context_policy_json = context_policy_json  # type: ignore[assignment]

            tags_json_str = _json.dumps(getattr(project, "tags", []) or [])
            row.tags_json = tags_json_str  # type: ignore[assignment]
            row.updated_at = datetime.now(tz=UTC).isoformat()  # type: ignore[assignment]

            session.flush()
            session.commit()
            return project
        except ProjectNotFoundError:
            raise
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            session.rollback()
            raise DatabaseError(f"Failed to update project '{ns_name}': {exc}") from exc
        finally:
            session.close()

    @database_retry
    def delete(self, namespaced_name: str) -> bool:
        """Delete a project by namespaced name.

        Cascades to any linked ``ProjectResourceLinkModel`` rows.

        Args:
            namespaced_name: The namespaced name string of the project.

        Returns:
            ``True`` if the project was deleted, ``False`` if not found.

        Raises:
            DatabaseError: On transient or unexpected DB errors.
        """
        session = self._session()
        try:
            row = (
                session.query(NamespacedProjectModel)
                .filter_by(namespaced_name=namespaced_name)
                .first()
            )
            if row is None:
                return False
            session.delete(row)
            session.flush()
            session.commit()
            return True
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            session.rollback()
            raise DatabaseError(
                f"Failed to delete project '{namespaced_name}': {exc}"
            ) from exc
        finally:
            session.close()


# ---------------------------------------------------------------------------
# Project Resource Link Repository
# ---------------------------------------------------------------------------


class ProjectResourceLinkRepository:
    """Repository for project-resource link persistence.

    Uses a session-factory pattern: each public method obtains its own
    session from the factory, ensuring proper session lifecycle management.
    All mutating methods (create_link, remove_link) flush and commit
    within their own session, then close the session.
    """

    def __init__(self, session_factory: Callable[[], Session]) -> None:
        """Initialise with a callable that returns a new SQLAlchemy Session."""
        self._session_factory = session_factory

    def _session(self) -> Session:
        """Convenience helper to obtain a session."""
        return self._session_factory()

    @database_retry
    def create_link(
        self,
        project_name: str,
        resource_id: str,
        alias: str | None = None,
        read_only: bool = False,
    ) -> Any:
        """Create a project-resource link.

        Args:
            project_name: Namespaced project name (FK to ns_projects).
            resource_id: ULID of the resource (FK to resources).
            alias: Optional alias for the resource within the project.
            read_only: Whether the resource is read-only in this project context.

        Returns:
            The ``ProjectResourceLinkModel`` ORM instance (with ``link_id``).

        Raises:
            DuplicateLinkError: If the alias already exists within the project.
            DatabaseError: On transient or unexpected DB errors.
        """
        from ulid import ULID as _ULID

        session = self._session()
        try:
            # Enforce alias uniqueness within the project
            if alias is not None:
                existing = (
                    session.query(ProjectResourceLinkModel)
                    .filter_by(project_name=project_name, alias=alias)
                    .first()
                )
                if existing is not None:
                    raise DuplicateLinkError(project_name, alias)

            link = ProjectResourceLinkModel(
                link_id=str(_ULID()),
                project_name=project_name,
                resource_id=resource_id,
                alias=alias,
                read_only=read_only,
                created_at=datetime.now(tz=UTC).isoformat(),
            )
            session.add(link)
            session.flush()
            session.commit()
            # Refresh attributes before closing the session so the
            # returned ORM instance is fully loaded and can be used
            # after the session is closed (detached state).
            session.refresh(link)
            session.expunge(link)
            return link
        except DuplicateLinkError:
            raise
        except IntegrityError as exc:
            session.rollback()
            raise DatabaseError(f"Failed to create link: {exc}") from exc
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            session.rollback()
            raise DatabaseError(f"Failed to create link: {exc}") from exc
        finally:
            session.close()

    @database_retry
    def list_links(self, project_name: str) -> list[Any]:
        """List all resource links for a project.

        Args:
            project_name: Namespaced project name.

        Returns:
            List of ``ProjectResourceLinkModel`` ORM instances.
        """
        session = self._session()
        try:
            rows = (
                session.query(ProjectResourceLinkModel)
                .filter_by(project_name=project_name)
                .order_by(ProjectResourceLinkModel.created_at)
                .all()
            )
            return list(rows)
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            raise DatabaseError(
                f"Failed to list links for project '{project_name}': {exc}"
            ) from exc

    @database_retry
    def get_link(self, link_id: str) -> Any:
        """Get a specific link by its ULID.

        Args:
            link_id: The ULID of the link.

        Returns:
            The ``ProjectResourceLinkModel`` ORM instance, or ``None``.
        """
        session = self._session()
        try:
            row = (
                session.query(ProjectResourceLinkModel)
                .filter_by(link_id=link_id)
                .first()
            )
            return row
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            raise DatabaseError(f"Failed to get link '{link_id}': {exc}") from exc

    @database_retry
    def remove_link(self, link_id: str) -> bool:
        """Remove a link by its ULID.

        Args:
            link_id: The ULID of the link to remove.

        Returns:
            ``True`` if the link was removed, ``False`` if not found.
        """
        session = self._session()
        try:
            row = (
                session.query(ProjectResourceLinkModel)
                .filter_by(link_id=link_id)
                .first()
            )
            if row is None:
                return False
            session.delete(row)
            session.flush()
            session.commit()
            return True
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            session.rollback()
            raise DatabaseError(f"Failed to remove link '{link_id}': {exc}") from exc
        finally:
            session.close()


# ---------------------------------------------------------------------------
# Tool Registry Repositories
# ---------------------------------------------------------------------------


class DuplicateToolError(DatabaseError):
    """Raised when creating a tool with a name that already exists."""

    def __init__(self, name: str):
        super().__init__(f"Tool with name '{name}' already exists")
        self.tool_name = name


class ToolNotFoundError(DatabaseError):
    """Raised when a requested tool is not found."""

    def __init__(self, name: str):
        super().__init__(f"Tool '{name}' not found")
        self.tool_name = name


class InvalidToolTypeError(BusinessRuleViolation):
    """Raised when a tool has an unsupported tool_type value."""

    def __init__(self, tool_type: str):
        super().__init__(f"Invalid tool_type '{tool_type}'")
        self.tool_type = tool_type


class ToolInUseError(BusinessRuleViolation):
    """Raised when deleting a tool that still has validation attachments."""

    def __init__(self, tool_name: str, attachment_count: int):
        super().__init__(
            f"Cannot delete tool {tool_name}: "
            f"still has {attachment_count} validation attachment(s)"
        )
        self.tool_name = tool_name
        self.attachment_count = attachment_count


class DuplicateValidationAttachmentError(DatabaseError):
    """Raised when attaching a validation that already exists."""

    def __init__(
        self,
        validation_name: str,
        resource_id: str,
        project_name: str | None = None,
        plan_id: str | None = None,
    ) -> None:
        detail = (
            f"validation '{validation_name}' already attached to resource "
            f"'{resource_id}'"
        )
        if project_name is not None:
            detail += f" for project '{project_name}'"
        if plan_id is not None:
            detail += f" and plan '{plan_id}'"
        super().__init__(detail)
        self.validation_name = validation_name
        self.resource_id = resource_id
        self.project_name = project_name
        self.plan_id = plan_id


class ToolRegistryRepository:
    """Repository for tool and validation persistence.

    Uses a session-factory pattern: each public method obtains its own
    session from the factory, ensuring proper session lifecycle management.

    All mutating methods flush **and commit** the transaction, then close
    the session.  This guarantees that changes are persisted even when no
    external ``UnitOfWork`` wrapper is present (e.g. the CLI factory).
    """

    def __init__(self, session_factory: Callable[[], Session]) -> None:
        """Initialise with a callable that returns a new SQLAlchemy Session."""
        self._session_factory = session_factory

    def _session(self) -> Session:
        """Convenience helper to obtain a session."""
        return self._session_factory()

    @database_retry
    def create(self, tool: Any) -> Any:
        """Persist a new tool domain object.

        Args:
            tool: A dict-like or domain tool object.

        Returns:
            The tool after persistence.

        Raises:
            DuplicateToolError: If a tool with the same name already exists.
            DatabaseError: On transient or unexpected DB errors.
        """
        session = self._session()
        try:
            db_model = ToolModel.from_domain(tool)
            session.add(db_model)
            session.flush()
            session.commit()
            return tool
        except IntegrityError as exc:
            session.rollback()
            name_str = (
                tool.get("name", "")
                if isinstance(tool, dict)
                else getattr(tool, "name", "")
            )
            if "UNIQUE" in str(exc).upper() or "unique" in str(exc).lower():
                raise DuplicateToolError(name_str) from exc
            raise DatabaseError(f"Failed to create tool: {exc}") from exc
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            session.rollback()
            raise DatabaseError(f"Failed to create tool: {exc}") from exc
        finally:
            session.close()

    @database_retry
    def get_by_name(self, name: str) -> Any | None:
        """Retrieve a tool by its namespaced name (PK).

        Returns:
            The tool as a dict, or ``None`` if not found.
        """
        session = self._session()
        try:
            row = session.query(ToolModel).filter_by(name=name).first()
            if row is None:
                return None
            return row.to_domain()
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            raise DatabaseError(f"Failed to get tool {name}: {exc}") from exc

    @database_retry
    def list_all(
        self,
        namespace: str | None = None,
        tool_type: str | None = None,
        source: str | None = None,
    ) -> list[Any]:
        """List tools with optional filters.

        Args:
            namespace: Optional namespace filter.
            tool_type: Optional tool_type filter (``tool`` or ``validation``).
            source: Optional source filter.

        Returns:
            List of tool dicts.
        """
        session = self._session()
        try:
            query = session.query(ToolModel)
            if namespace is not None:
                query = query.filter(ToolModel.namespace == namespace)
            if tool_type is not None:
                query = query.filter(ToolModel.tool_type == tool_type)
            if source is not None:
                query = query.filter(ToolModel.source == source)
            rows = query.order_by(ToolModel.name).all()
            return [row.to_domain() for row in rows]
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            raise DatabaseError(f"Failed to list tools: {exc}") from exc

    @database_retry
    def update(self, tool: Any) -> Any:
        """Update all mutable fields of an existing tool.

        Replaces child resource bindings entirely.

        Args:
            tool: The tool dict or object with updated fields.

        Returns:
            The tool after persistence.

        Raises:
            DatabaseError: If the tool is not found or a DB error occurs.
        """
        session = self._session()

        def _get(obj: Any, key: str, default: Any = None) -> Any:
            if isinstance(obj, dict):
                return obj.get(key, default)
            return getattr(obj, key, default)

        name_str: str = _get(tool, "name", "")
        try:
            row = session.query(ToolModel).filter_by(name=name_str).first()
            if row is None:
                raise DatabaseError(f"Tool '{name_str}' not found for update")

            from datetime import datetime as _dt

            row.namespace = name_str.split("/", 1)[0] if "/" in name_str else ""  # type: ignore[assignment]
            row.short_name = name_str.split("/", 1)[1] if "/" in name_str else name_str  # type: ignore[assignment]
            row.description = _get(tool, "description", "")  # type: ignore[assignment]
            row.tool_type = _get(tool, "tool_type", "tool")  # type: ignore[assignment]
            row.source = _get(tool, "source", "builtin")  # type: ignore[assignment]
            row.input_schema_json = _get(tool, "input_schema_json")  # type: ignore[assignment]
            row.output_schema_json = _get(tool, "output_schema_json")  # type: ignore[assignment]
            row.capability_json = _get(tool, "capability_json")  # type: ignore[assignment]
            row.lifecycle_json = _get(tool, "lifecycle_json")  # type: ignore[assignment]
            row.code = _get(tool, "code")  # type: ignore[assignment]
            row.mcp_server = _get(tool, "mcp_server")  # type: ignore[assignment]
            row.mcp_tool_name = _get(tool, "mcp_tool_name")  # type: ignore[assignment]
            row.agent_skill_path = _get(tool, "agent_skill_path")  # type: ignore[assignment]
            row.timeout = _get(tool, "timeout", 300)  # type: ignore[assignment]
            row.wraps = _get(tool, "wraps")  # type: ignore[assignment]
            row.transform = _get(tool, "transform")  # type: ignore[assignment]
            row.mode = _get(tool, "mode")  # type: ignore[assignment]
            row.argument_mapping_json = _get(tool, "argument_mapping_json")  # type: ignore[assignment]
            row.updated_at = _dt.now().isoformat()  # type: ignore[assignment]

            # Replace child resource bindings
            row.resource_bindings_rel.clear()  # type: ignore[union-attr]
            now_iso = _dt.now().isoformat()
            for binding_data in _get(tool, "resource_bindings", []) or []:
                row.resource_bindings_rel.append(  # type: ignore[union-attr]
                    ToolResourceBindingModel(
                        slot_name=_get(binding_data, "slot_name", ""),
                        resource_type=_get(binding_data, "resource_type", ""),
                        access_mode=_get(binding_data, "access_mode", "read_only"),
                        binding_mode=_get(binding_data, "binding_mode", "contextual"),
                        static_resource=_get(binding_data, "static_resource"),
                        required=_get(binding_data, "required", True),
                        description=_get(binding_data, "description"),
                        created_at=now_iso,
                    )
                )

            session.flush()
            session.commit()
            return tool
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            session.rollback()
            raise DatabaseError(f"Failed to update tool {name_str}: {exc}") from exc
        finally:
            session.close()

    @database_retry
    def delete(self, name: str) -> bool:
        """Delete a tool by namespaced name.

        Before deletion, verifies the tool has no validation attachments.

        Args:
            name: The namespaced name of the tool to delete.

        Returns:
            ``True`` if the tool was deleted.

        Raises:
            ToolInUseError: If validation attachments still reference this tool.
            DatabaseError: On transient or unexpected DB errors.
        """
        session = self._session()
        try:
            # Check for validation attachments
            attachment_count: int = (
                session.query(ValidationAttachmentModel)
                .filter(ValidationAttachmentModel.validation_name == name)
                .count()
            )
            if attachment_count > 0:
                raise ToolInUseError(name, attachment_count)

            row = session.query(ToolModel).filter_by(name=name).first()
            if row is None:
                return False
            session.delete(row)
            session.flush()
            session.commit()
            return True
        except ToolInUseError:
            raise
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            session.rollback()
            raise DatabaseError(f"Failed to delete tool {name}: {exc}") from exc
        finally:
            session.close()


class ToolRepository(ToolRegistryRepository):
    """Compatibility wrapper around ``ToolRegistryRepository``.

    Provides the legacy ``add / get / remove`` method names expected by
    older test suites while delegating to the canonical repository.
    """

    _VALID_TOOL_TYPES: ClassVar[set[str]] = {"tool", "validation"}

    # Accept both positional ``session_factory`` and keyword ``factory``
    def __init__(
        self,
        session_factory: Callable[[], Session] | None = None,
        *,
        factory: Callable[[], Session] | None = None,
    ) -> None:
        effective = session_factory or factory
        if effective is None:
            raise TypeError("session_factory (or factory=) is required")
        super().__init__(session_factory=effective)

    # -- helpers -----------------------------------------------------------

    @staticmethod
    def _extract_value(obj: Any, key: str, default: Any = None) -> Any:
        """Get a scalar value, unwrapping enum-like ``.value`` attributes.

        Only unwraps ``.value`` for objects whose type explicitly defines
        a ``value`` descriptor (e.g. Python enums or ``SimpleNamespace``
        with a ``value`` field).  MagicMock objects auto-create ``.value``
        on access, so we guard against that.
        """
        _SCALAR_TYPES = (str, int, float, bool, bytes, type(None), dict, list)

        raw = (
            getattr(obj, key, default)
            if not isinstance(obj, dict)
            else obj.get(key, default)
        )
        if raw is not None and "value" in raw.__class__.__dict__:
            return raw.value
        if (
            raw is not None
            and hasattr(raw, "__dict__")
            and "value" in getattr(raw, "__dict__", {})
        ):
            return raw.value
        if not isinstance(raw, _SCALAR_TYPES):
            return default
        return raw

    def _prepare_tool_dict(self, tool: Any) -> dict[str, Any]:
        """Convert a domain / mock tool object into a dict understood by
        ``ToolRegistryRepository.create``."""
        import json as _json

        ev = self._extract_value
        name = ev(tool, "name", "")

        input_schema = ev(tool, "input_schema")
        output_schema = ev(tool, "output_schema")

        cap = getattr(tool, "capability", None)
        capability_dict: dict[str, Any] | None = None
        if cap is not None:
            capability_dict = {
                "read_only": getattr(cap, "read_only", False),
                "writes": getattr(cap, "writes", False),
                "checkpointable": getattr(cap, "checkpointable", False),
                "side_effects": getattr(cap, "side_effects", False),
            }

        bindings: list[dict[str, Any]] = []
        for slot in getattr(tool, "resource_slots", None) or []:
            bindings.append(
                {
                    "slot_name": ev(slot, "name", ""),
                    "resource_type": ev(slot, "resource_type", ""),
                    "access_mode": ev(slot, "access", "read_only"),
                    "binding_mode": ev(slot, "binding", "contextual"),
                    "static_resource": ev(slot, "static_resource"),
                    "required": ev(slot, "required", True),
                    "description": ev(slot, "description"),
                }
            )

        return {
            "name": name,
            "description": ev(tool, "description", ""),
            "tool_type": ev(tool, "tool_type", "tool"),
            "source": ev(tool, "source", "builtin"),
            "input_schema_json": (_json.dumps(input_schema) if input_schema else None),
            "output_schema_json": (
                _json.dumps(output_schema) if output_schema else None
            ),
            "capability_json": (
                _json.dumps(capability_dict) if capability_dict else None
            ),
            "lifecycle_json": ev(tool, "lifecycle_json"),
            "code": ev(tool, "code"),
            "mcp_server": ev(tool, "mcp_server"),
            "mcp_tool_name": ev(tool, "mcp_tool_name"),
            "agent_skill_path": ev(tool, "agent_skill_path"),
            "timeout": ev(tool, "timeout", 300),
            "wraps": ev(tool, "wraps"),
            "transform": ev(tool, "transform"),
            "mode": ev(tool, "mode"),
            "argument_mapping_json": ev(tool, "argument_mapping_json"),
            "resource_bindings": bindings,
        }

    # -- public API --------------------------------------------------------

    def add(self, tool: Any) -> str:
        """Persist a tool and return the tool name (identifier)."""
        tool_type = self._extract_value(tool, "tool_type", "tool")
        if tool_type not in self._VALID_TOOL_TYPES:
            raise InvalidToolTypeError(str(tool_type))

        prepared = self._prepare_tool_dict(tool)
        try:
            self.create(prepared)
        except DuplicateToolError:
            raise
        except DatabaseError as exc:
            raise DatabaseError(f"Failed to add tool: {exc}") from exc
        return prepared["name"]

    def get(self, tool_id: str) -> Any | None:
        session = self._session()
        try:
            row = session.query(ToolModel).filter_by(name=tool_id).first()
            if row is None:
                return None
            return self._to_legacy_domain(row)
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            raise DatabaseError(f"Failed to get tool {tool_id}: {exc}") from exc

    def get_by_name(self, name: str) -> Any | None:
        session = self._session()
        try:
            row = session.query(ToolModel).filter_by(name=name).first()
            if row is None:
                return None
            return self._to_legacy_domain(row)
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            raise DatabaseError(f"Failed to get tool by name '{name}': {exc}") from exc

    def remove(self, name: str) -> bool:
        try:
            deleted = self.delete(name)
        except ToolInUseError:
            raise
        except DatabaseError as exc:
            raise DatabaseError(f"Failed to remove tool {name}: {exc}") from exc

        if not deleted:
            raise ToolNotFoundError(name)
        return deleted

    def update(self, tool: Any) -> Any:
        name_str = (
            tool.get("name", "")
            if isinstance(tool, dict)
            else getattr(tool, "name", "")
        )
        try:
            return super().update(tool)
        except DatabaseError as exc:
            if "not found" in str(exc).lower():
                raise ToolNotFoundError(name_str) from exc
            raise

    @staticmethod
    def _to_legacy_domain(row: ToolModel) -> Any:
        from types import SimpleNamespace

        return SimpleNamespace(
            name=cast(str, row.name),
            input_schema=row.input_schema,
            output_schema=row.output_schema,
            tool_type=cast(str, row.tool_type),
            source=cast(str, row.source),
            mode=cast(str, row.mode) if row.mode is not None else None,
        )


class ValidationAttachmentRepository:
    """Repository for validation attachment persistence.

    Uses a session-factory pattern matching ``ActionRepository``.
    All mutating methods flush **and commit** the transaction, then close
    the session to ensure data is persisted even without an external
    ``UnitOfWork``.
    """

    def __init__(self, session_factory: Callable[[], Session]) -> None:
        """Initialise with a callable that returns a new SQLAlchemy Session."""
        self._session_factory = session_factory

    def _session(self) -> Session:
        """Convenience helper to obtain a session."""
        return self._session_factory()

    @database_retry
    def attach(
        self,
        validation_name: str,
        resource_id: str,
        mode: str = "required",
        project_name: str | None = None,
        plan_id: str | None = None,
        args: dict[str, Any] | None = None,
    ) -> Any:
        """Create a validation attachment.

        Args:
            validation_name: Name of the validation tool.
            resource_id: Resource reference string.
            mode: ``required`` or ``informational``.
            project_name: Optional project scope.
            plan_id: Optional plan scope.
            args: Optional argument dict (stored as JSON).

        Returns:
            A dict representing the attachment.
        """
        import json as _json

        from ulid import ULID as _ULID

        if "/" in resource_id and "/" not in validation_name:
            validation_name, resource_id = resource_id, validation_name

        session = self._session()
        try:
            # Check for existing attachment with same validation+resource+scope
            dup_conditions = [
                ValidationAttachmentModel.validation_name == validation_name,
                ValidationAttachmentModel.resource_id == resource_id,
            ]
            if project_name is not None:
                dup_conditions.append(
                    ValidationAttachmentModel.project_name == project_name
                )
            else:
                dup_conditions.append(ValidationAttachmentModel.project_name.is_(None))
            if plan_id is not None:
                dup_conditions.append(ValidationAttachmentModel.plan_id == plan_id)
            else:
                dup_conditions.append(ValidationAttachmentModel.plan_id.is_(None))
            existing = (
                session.query(ValidationAttachmentModel).filter(*dup_conditions).first()
            )
            if isinstance(existing, ValidationAttachmentModel):
                raise DuplicateValidationAttachmentError(
                    validation_name,
                    resource_id,
                    project_name=project_name,
                    plan_id=plan_id,
                )

            now_iso = datetime.now().isoformat()
            attachment_id = str(_ULID())

            args_json: str | None = None
            if args is not None:
                args_json = _json.dumps(args)

            model = ValidationAttachmentModel(
                attachment_id=attachment_id,
                validation_name=validation_name,
                resource_id=resource_id,
                mode=mode,
                project_name=project_name,
                plan_id=plan_id,
                args_json=args_json,
                created_at=now_iso,
            )
            session.add(model)
            session.flush()
            session.commit()
            return {
                "attachment_id": attachment_id,
                "validation_name": validation_name,
                "resource_id": resource_id,
                "mode": mode,
                "project_name": project_name,
                "plan_id": plan_id,
                "args_json": args_json,
                "created_at": now_iso,
            }
        except IntegrityError as exc:
            session.rollback()
            if "UNIQUE" in str(exc).upper() or "unique" in str(exc).lower():
                raise DuplicateValidationAttachmentError(
                    validation_name,
                    resource_id,
                    project_name=project_name,
                    plan_id=plan_id,
                ) from exc
            raise DatabaseError(f"Failed to attach validation: {exc}") from exc
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            session.rollback()
            raise DatabaseError(f"Failed to attach validation: {exc}") from exc
        finally:
            session.close()

    @database_retry
    def detach(self, attachment_id: str, validation_name: str | None = None) -> bool:
        """Remove a validation attachment by its ULID.

        Args:
            attachment_id: The ULID of the attachment.

        Returns:
            ``True`` if the attachment was removed.
        """
        _ = validation_name
        session = self._session()
        try:
            row = (
                session.query(ValidationAttachmentModel)
                .filter_by(attachment_id=attachment_id)
                .first()
            )
            if row is None:
                return False
            session.delete(row)
            session.flush()
            session.commit()
            return True
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            session.rollback()
            raise DatabaseError(
                f"Failed to detach validation {attachment_id}: {exc}"
            ) from exc
        finally:
            session.close()

    @database_retry
    def list_for_resource(
        self,
        resource_id: str,
        project_name: str | None = None,
        plan_id: str | None = None,
    ) -> list[Any]:
        """List validation attachments for a resource.

        Args:
            resource_id: The resource reference string.
            project_name: Optional project scope filter.
            plan_id: Optional plan scope filter.

        Returns:
            List of attachment dicts.
        """
        session = self._session()
        try:
            query = session.query(ValidationAttachmentModel).filter(
                ValidationAttachmentModel.resource_id == resource_id
            )
            if project_name is not None:
                query = query.filter(
                    ValidationAttachmentModel.project_name == project_name
                )
            if plan_id is not None:
                query = query.filter(ValidationAttachmentModel.plan_id == plan_id)
            rows = query.order_by(ValidationAttachmentModel.created_at).all()
            results: list[dict[str, Any]] = []
            for row in rows:
                results.append(
                    {
                        "attachment_id": cast(str, row.attachment_id),
                        "validation_name": cast(str, row.validation_name),
                        "resource_id": cast(str, row.resource_id),
                        "mode": cast(str, row.mode),
                        "project_name": cast("str | None", row.project_name),
                        "plan_id": cast("str | None", row.plan_id),
                        "args_json": cast("str | None", row.args_json),
                        "created_at": cast(str, row.created_at),
                    }
                )
            return results
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            raise DatabaseError(
                f"Failed to list attachments for resource {resource_id}: {exc}"
            ) from exc

    @database_retry
    def get_by_id(self, attachment_id: str) -> Any | None:
        """Retrieve an attachment by its ULID.

        Returns:
            The attachment dict, or ``None`` if not found.
        """
        session = self._session()
        try:
            row = (
                session.query(ValidationAttachmentModel)
                .filter_by(attachment_id=attachment_id)
                .first()
            )
            if row is None:
                return None
            return {
                "attachment_id": cast(str, row.attachment_id),
                "validation_name": cast(str, row.validation_name),
                "resource_id": cast(str, row.resource_id),
                "mode": cast(str, row.mode),
                "project_name": cast("str | None", row.project_name),
                "plan_id": cast("str | None", row.plan_id),
                "args_json": cast("str | None", row.args_json),
                "created_at": cast(str, row.created_at),
            }
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            raise DatabaseError(
                f"Failed to get attachment {attachment_id}: {exc}"
            ) from exc


# ---------------------------------------------------------------------------
# Session Repository
# ---------------------------------------------------------------------------


class SessionRepository:
    """Repository for session persistence.

    Uses a session-factory pattern: each public method obtains its own
    session from the factory, ensuring proper session lifecycle management.

    All mutating methods flush (but do NOT commit) by default; the caller
    or a ``UnitOfWork`` wrapper is responsible for committing the
    transaction.  When ``auto_commit`` is ``True`` (e.g. CLI usage outside
    a UoW), each method commits and closes its own session.
    """

    def __init__(
        self,
        session_factory: Callable[[], Session],
        *,
        auto_commit: bool = False,
    ) -> None:
        """Initialise with a callable that returns a new SQLAlchemy Session.

        Args:
            session_factory: Factory returning a new SQLAlchemy ``Session``.
            auto_commit: When ``True``, each public method commits and
                closes its session automatically.  Useful for CLI commands
                that operate outside a ``UnitOfWork``.
        """
        self._session_factory = session_factory
        self._auto_commit = auto_commit

    def _session(self) -> Session:
        """Convenience helper to obtain a session."""
        return self._session_factory()

    @database_retry
    def create(self, session: Any) -> Any:
        """Persist a new ``Session`` domain object.

        Args:
            session: A ``Session`` domain model instance.

        Returns:
            The same ``Session`` after persistence.

        Raises:
            DatabaseError: On transient or unexpected DB errors.
        """
        db_session = self._session()
        try:
            db_model = SessionModel.from_domain(session)
            db_session.add(db_model)
            db_session.flush()
            if self._auto_commit:
                db_session.commit()
            return session
        except IntegrityError as exc:
            db_session.rollback()
            raise DatabaseError(f"Failed to create session: {exc}") from exc
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            db_session.rollback()
            raise DatabaseError(f"Failed to create session: {exc}") from exc
        finally:
            if self._auto_commit:
                db_session.close()

    @database_retry
    def get_by_id(self, session_id: str) -> Any | None:
        """Retrieve a session by its ULID.

        Returns:
            The ``Session`` domain object, or ``None`` if not found.
        """
        db_session = self._session()
        try:
            row = (
                db_session.query(SessionModel).filter_by(session_id=session_id).first()
            )
            if row is None:
                return None
            return row.to_domain()
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            raise DatabaseError(f"Failed to get session {session_id}: {exc}") from exc
        finally:
            if self._auto_commit:
                db_session.close()

    @database_retry
    def list_all(self, actor_name: str | None = None) -> list[Any]:
        """List all sessions, optionally filtered by actor_name.

        Args:
            actor_name: Optional actor name filter.

        Returns:
            List of ``Session`` domain objects ordered by created_at.
        """
        db_session = self._session()
        try:
            query = db_session.query(SessionModel)
            if actor_name is not None:
                query = query.filter_by(actor_name=actor_name)
            rows = query.order_by(SessionModel.created_at).all()
            return [row.to_domain() for row in rows]
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            raise DatabaseError(f"Failed to list sessions: {exc}") from exc
        finally:
            if self._auto_commit:
                db_session.close()

    @database_retry
    def delete(self, session_id: str) -> bool:
        """Delete a session by ULID.

        Args:
            session_id: The ULID of the session to delete.

        Returns:
            ``True`` if the session was deleted, ``False`` if not found.
        """
        db_session = self._session()
        try:
            row = (
                db_session.query(SessionModel).filter_by(session_id=session_id).first()
            )
            if row is None:
                return False
            db_session.delete(row)
            db_session.flush()
            if self._auto_commit:
                db_session.commit()
            return True
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            db_session.rollback()
            raise DatabaseError(
                f"Failed to delete session {session_id}: {exc}"
            ) from exc
        finally:
            if self._auto_commit:
                db_session.close()

    @database_retry
    def update(self, session: Any) -> Any:
        """Update all mutable fields of an existing session.

        Args:
            session: The ``Session`` domain model with updated fields.

        Returns:
            The same ``Session`` after persistence.

        Raises:
            DatabaseError: If the session is not found or a DB error occurs.
        """
        db_session = self._session()
        try:
            row = (
                db_session.query(SessionModel)
                .filter_by(session_id=session.session_id)
                .first()
            )
            if row is None:
                raise DatabaseError(
                    f"Session {session.session_id} not found for update"
                )

            import json as _json

            row.actor_name = session.actor_name  # type: ignore[assignment]
            row.namespace = session.namespace  # type: ignore[assignment]
            row.linked_plan_ids_json = _json.dumps(  # type: ignore[assignment]
                session.linked_plan_ids
            )
            token_usage_str = _json.dumps(
                {
                    "input_tokens": session.token_usage.input_tokens,
                    "output_tokens": session.token_usage.output_tokens,
                    "estimated_cost": session.token_usage.estimated_cost,
                }
            )
            row.token_usage_json = token_usage_str  # type: ignore[assignment]
            row.metadata_json = _json.dumps(session.metadata)  # type: ignore[assignment]
            row.updated_at = session.updated_at.isoformat()  # type: ignore[assignment]

            db_session.flush()
            if self._auto_commit:
                db_session.commit()
            return session
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            db_session.rollback()
            raise DatabaseError(
                f"Failed to update session {session.session_id}: {exc}"
            ) from exc
        finally:
            if self._auto_commit:
                db_session.close()


# ---------------------------------------------------------------------------
# Session Message Repository
# ---------------------------------------------------------------------------


class SessionMessageRepository:
    """Repository for session message persistence.

    Uses a session-factory pattern matching ``ActionRepository``.
    All mutating methods flush but do NOT commit by default.  When
    ``auto_commit`` is ``True``, each method commits and closes its
    own session (for CLI usage outside a ``UnitOfWork``).
    """

    def __init__(
        self,
        session_factory: Callable[[], Session],
        *,
        auto_commit: bool = False,
    ) -> None:
        """Initialise with a callable that returns a new SQLAlchemy Session.

        Args:
            session_factory: Factory returning a new SQLAlchemy ``Session``.
            auto_commit: When ``True``, commit and close after each op.
        """
        self._session_factory = session_factory
        self._auto_commit = auto_commit

    def _session(self) -> Session:
        """Convenience helper to obtain a session."""
        return self._session_factory()

    @database_retry
    def append(self, session_id: str, message: Any) -> Any:
        """Persist a new message for the given session.

        Args:
            session_id: The ULID of the parent session.
            message: A ``SessionMessage`` domain instance.

        Returns:
            The same ``SessionMessage`` after persistence.
        """
        db_session = self._session()
        try:
            db_model = SessionMessageModel.from_domain(message)
            db_model.session_id = session_id  # type: ignore[assignment]
            db_session.add(db_model)
            db_session.flush()
            if self._auto_commit:
                db_session.commit()
            return message
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            db_session.rollback()
            raise DatabaseError(
                f"Failed to append message to session {session_id}: {exc}"
            ) from exc
        finally:
            if self._auto_commit:
                db_session.close()

    @database_retry
    def get_for_session(
        self,
        session_id: str,
        limit: int | None = None,
        offset: int = 0,
    ) -> list[Any]:
        """Retrieve messages for a session with optional pagination.

        Args:
            session_id: The ULID of the session.
            limit: Maximum number of messages to return (None = all).
            offset: Number of messages to skip.

        Returns:
            List of ``SessionMessage`` domain objects ordered by sequence.
        """
        db_session = self._session()
        try:
            query = (
                db_session.query(SessionMessageModel)
                .filter_by(session_id=session_id)
                .order_by(SessionMessageModel.sequence)
                .offset(offset)
            )
            if limit is not None:
                query = query.limit(limit)
            rows = query.all()
            return [row.to_domain() for row in rows]
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            raise DatabaseError(
                f"Failed to get messages for session {session_id}: {exc}"
            ) from exc
        finally:
            if self._auto_commit:
                db_session.close()

    @database_retry
    def count_for_session(self, session_id: str) -> int:
        """Count messages in a session.

        Args:
            session_id: The ULID of the session.

        Returns:
            Number of messages.
        """
        db_session = self._session()
        try:
            return (
                db_session.query(SessionMessageModel)
                .filter_by(session_id=session_id)
                .count()
            )
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            raise DatabaseError(
                f"Failed to count messages for session {session_id}: {exc}"
            ) from exc
        finally:
            if self._auto_commit:
                db_session.close()


# AutomationProfile Repository Errors
# ---------------------------------------------------------------------------


class AutomationProfileNotFoundError(DatabaseError):
    """Raised when a requested automation profile is not found."""


class DuplicateAutomationProfileError(DatabaseError):
    """Raised when a profile with the same name already exists."""


class AutomationProfileSchemaVersionError(DatabaseError):
    """Raised on schema_version mismatch during update."""


# ---------------------------------------------------------------------------
# AutomationProfileRepository
# ---------------------------------------------------------------------------


class AutomationProfileRepository:
    """Repository for persisted custom automation profiles.

    Uses the session-factory pattern consistent with other repositories.
    Built-in profiles are NOT stored here.

    All mutating methods flush but do NOT commit by default; the caller
    or a ``UnitOfWork`` wrapper is responsible for committing the
    transaction.  When ``auto_commit`` is ``True`` (e.g. CLI usage
    outside a UoW), each method commits and closes its own session.
    """

    def __init__(
        self,
        session_factory: Callable[[], Session],
        *,
        auto_commit: bool = False,
    ) -> None:
        """Initialise with a callable that returns a new SQLAlchemy Session.

        Args:
            session_factory: Factory returning a new SQLAlchemy ``Session``.
            auto_commit: When ``True``, each public method commits and
                closes its session automatically.  Useful for CLI commands
                that operate outside a ``UnitOfWork``.
        """
        self._session_factory = session_factory
        self._auto_commit = auto_commit

    def _session(self) -> Session:
        """Convenience helper to obtain a session."""
        return self._session_factory()

    @database_retry
    def get_by_name(self, name: str) -> Any | None:
        """Look up a profile by its namespaced name.

        Args:
            name: Profile name (PK).

        Returns:
            An ``AutomationProfile`` domain object or ``None``.
        """
        session = self._session()
        try:
            row = session.query(AutomationProfileModel).filter_by(name=name).first()
            if row is None:
                return None
            return self._to_domain(row)
        except (
            OperationalError,
            SQLAlchemyDatabaseError,
        ) as exc:
            raise DatabaseError(f"Failed to get profile '{name}': {exc}") from exc
        finally:
            if self._auto_commit:
                session.close()

    @database_retry
    def list_all(self) -> list[Any]:
        """List all persisted profiles.

        Returns:
            List of ``AutomationProfile`` domain objects.
        """
        session = self._session()
        try:
            rows = (
                session.query(AutomationProfileModel)
                .order_by(AutomationProfileModel.name)
                .all()
            )
            return [self._to_domain(r) for r in rows]
        except (
            OperationalError,
            SQLAlchemyDatabaseError,
        ) as exc:
            raise DatabaseError(f"Failed to list profiles: {exc}") from exc
        finally:
            if self._auto_commit:
                session.close()

    @database_retry
    def upsert(
        self,
        profile: Any,
        expected_schema_version: str | None = None,
    ) -> None:
        """Insert or update a profile.

        If ``expected_schema_version`` is provided, the update is
        rejected when the stored version does not match (optimistic
        concurrency guard).

        Args:
            profile: An ``AutomationProfile`` domain object.
            expected_schema_version: Optional schema version guard.

        Raises:
            AutomationProfileSchemaVersionError: On version mismatch.
            DatabaseError: On transient or unexpected DB errors.
        """
        session = self._session()
        try:
            now_iso = datetime.now(tz=UTC).isoformat()
            existing = (
                session.query(AutomationProfileModel)
                .filter_by(name=profile.name)
                .first()
            )
            if existing is not None:
                if expected_schema_version is not None:
                    stored_ver = cast(str, existing.schema_version)
                    if stored_ver != expected_schema_version:
                        raise AutomationProfileSchemaVersionError(
                            f"Schema version mismatch for "
                            f"'{profile.name}': expected "
                            f"'{expected_schema_version}', "
                            f"got '{stored_ver}'"
                        )
                self._update_row(existing, profile, now_iso)
            else:
                row = self._from_domain(profile, now_iso)
                session.add(row)
            session.flush()
            if self._auto_commit:
                session.commit()
        except AutomationProfileSchemaVersionError:
            raise
        except IntegrityError as exc:
            session.rollback()
            raise DuplicateAutomationProfileError(
                f"Profile '{profile.name}' already exists: {exc}"
            ) from exc
        except (
            OperationalError,
            SQLAlchemyDatabaseError,
        ) as exc:
            session.rollback()
            raise DatabaseError(
                f"Failed to upsert profile '{profile.name}': {exc}"
            ) from exc
        finally:
            if self._auto_commit:
                session.close()

    @database_retry
    def delete(self, name: str) -> None:
        """Delete a profile by name.

        Args:
            name: Profile name (PK).

        Raises:
            AutomationProfileNotFoundError: If not found.
            DatabaseError: On transient or unexpected DB errors.
        """
        session = self._session()
        try:
            row = session.query(AutomationProfileModel).filter_by(name=name).first()
            if row is None:
                raise AutomationProfileNotFoundError(f"Profile '{name}' not found")
            session.delete(row)
            session.flush()
            if self._auto_commit:
                session.commit()
        except AutomationProfileNotFoundError:
            raise
        except (
            OperationalError,
            SQLAlchemyDatabaseError,
        ) as exc:
            session.rollback()
            raise DatabaseError(f"Failed to delete profile '{name}': {exc}") from exc
        finally:
            if self._auto_commit:
                session.close()

    @staticmethod
    def _to_domain(
        row: AutomationProfileModel,
    ) -> Any:
        """Convert an ORM row to an AutomationProfile domain object."""
        import json as _json

        from cleveragents.domain.models.core.automation_profile import (
            AutomationGuard,
            AutomationProfile,
        )
        from cleveragents.domain.models.core.safety_profile import SafetyProfile

        # Restore full safety from JSON if available, else fall back to
        # legacy scalar columns for backward compatibility.
        safety_json_str = (
            cast(str | None, row.safety_json) if hasattr(row, "safety_json") else None
        )
        if safety_json_str:
            safety = SafetyProfile(**_json.loads(safety_json_str))
        else:
            safety = SafetyProfile(
                require_sandbox=bool(row.require_sandbox),
                require_checkpoints=bool(row.require_checkpoints),
                require_human_approval=False,
                allow_unsafe_tools=bool(row.allow_unsafe_tools),
                max_retries_per_step=3,
            )

        # Restore guards from JSON if available.
        guards_json_str = (
            cast(str | None, row.guards_json) if hasattr(row, "guards_json") else None
        )
        guards: AutomationGuard | None = None
        if guards_json_str:
            guards = AutomationGuard(**_json.loads(guards_json_str))

        return AutomationProfile(
            name=cast(str, row.name),
            description=cast(str, row.description) or "",
            schema_version=cast(str, row.schema_version),
            decompose_task=float(cast(float, row.decompose_task)),
            create_tool=float(cast(float, row.create_tool)),
            select_tool=float(cast(float, row.select_tool)),
            edit_code=float(cast(float, row.edit_code)),
            execute_command=float(cast(float, row.execute_command)),
            create_file=float(cast(float, row.create_file)),
            delete_content=float(cast(float, row.delete_content)),
            access_network=float(cast(float, row.access_network)),
            install_dependency=float(cast(float, row.install_dependency)),
            modify_config=float(cast(float, row.modify_config)),
            approve_plan=float(cast(float, row.approve_plan)),
            safety=safety,
            guards=guards,
        )

    @staticmethod
    def _from_domain(
        profile: Any,
        now_iso: str,
    ) -> AutomationProfileModel:
        """Convert an AutomationProfile to an ORM row."""
        import json as _json

        safety_json = (
            _json.dumps(profile.safety.model_dump()) if profile.safety else None
        )
        guards_json = (
            _json.dumps(profile.guards.model_dump()) if profile.guards else None
        )

        return AutomationProfileModel(
            name=profile.name,
            description=profile.description,
            schema_version=profile.schema_version,
            decompose_task=profile.decompose_task,
            create_tool=profile.create_tool,
            select_tool=profile.select_tool,
            edit_code=(profile.edit_code),
            execute_command=(profile.execute_command),
            create_file=profile.create_file,
            delete_content=(profile.delete_content),
            access_network=(profile.access_network),
            install_dependency=profile.install_dependency,
            modify_config=(profile.modify_config),
            approve_plan=(profile.approve_plan),
            require_sandbox=profile.safety.require_sandbox,
            require_checkpoints=profile.safety.require_checkpoints,
            allow_unsafe_tools=profile.safety.allow_unsafe_tools,
            safety_json=safety_json,
            guards_json=guards_json,
            created_at=now_iso,
            updated_at=now_iso,
        )

    @staticmethod
    def _update_row(
        row: AutomationProfileModel,
        profile: Any,
        now_iso: str,
    ) -> None:
        """Update an existing ORM row from a domain object."""
        row.description = profile.description  # type: ignore[assignment]
        row.schema_version = (  # type: ignore[assignment]
            profile.schema_version
        )
        row.decompose_task = (  # type: ignore[assignment]
            profile.decompose_task
        )
        row.create_tool = (  # type: ignore[assignment]
            profile.create_tool
        )
        row.select_tool = (  # type: ignore[assignment]
            profile.select_tool
        )
        row.edit_code = (  # type: ignore[assignment]
            profile.edit_code
        )
        row.execute_command = (  # type: ignore[assignment]
            profile.execute_command
        )
        row.create_file = (  # type: ignore[assignment]
            profile.create_file
        )
        row.delete_content = (  # type: ignore[assignment]
            profile.delete_content
        )
        row.access_network = (  # type: ignore[assignment]
            profile.access_network
        )
        row.install_dependency = (  # type: ignore[assignment]
            profile.install_dependency
        )
        row.modify_config = (  # type: ignore[assignment]
            profile.modify_config
        )
        row.approve_plan = (  # type: ignore[assignment]
            profile.approve_plan
        )
        row.require_sandbox = (  # type: ignore[assignment]
            profile.safety.require_sandbox
        )
        row.require_checkpoints = (  # type: ignore[assignment]
            profile.safety.require_checkpoints
        )
        row.allow_unsafe_tools = (  # type: ignore[assignment]
            profile.safety.allow_unsafe_tools
        )
        import json as _json

        row.safety_json = (  # type: ignore[assignment]
            _json.dumps(profile.safety.model_dump()) if profile.safety else None
        )
        row.guards_json = (  # type: ignore[assignment]
            _json.dumps(profile.guards.model_dump()) if profile.guards else None
        )
        row.updated_at = now_iso  # type: ignore[assignment]


# ---------------------------------------------------------------------------
# Skill Registry Errors
# ---------------------------------------------------------------------------


class DuplicateSkillError(DatabaseError):
    """Raised when creating a skill with a name that already exists."""

    def __init__(self, name: str):
        super().__init__(f"Skill with name '{name}' already exists")
        self.skill_name = name


class SkillInUseError(BusinessRuleViolation):
    """Raised when deleting a skill that is still referenced."""

    def __init__(self, skill_name: str, detail: str = ""):
        msg = f"Cannot delete skill '{skill_name}'"
        if detail:
            msg = f"{msg}: {detail}"
        super().__init__(msg)
        self.skill_name = skill_name


class SkillNotFoundError(DatabaseError):
    """Raised when a skill cannot be found for update or retrieval."""

    def __init__(self, name: str):
        super().__init__(f"Skill '{name}' not found")
        self.skill_name = name


# ---------------------------------------------------------------------------
# Skill Registry Repository
# ---------------------------------------------------------------------------


class SkillRepository:
    """Repository for skill persistence.

    Uses a session-factory pattern: each public method obtains its own
    session from the factory, ensuring proper session lifecycle management.

    All mutating methods flush (but do NOT commit) by default; the caller
    or a ``UnitOfWork`` wrapper is responsible for committing the
    transaction.  When ``auto_commit`` is ``True`` (e.g. CLI usage
    outside a UoW), each method commits and closes its own session.
    """

    def __init__(
        self,
        session_factory: Callable[[], Session],
        *,
        auto_commit: bool = False,
    ) -> None:
        """Initialise with a callable that returns a new SQLAlchemy Session.

        Args:
            session_factory: Factory returning a new SQLAlchemy ``Session``.
            auto_commit: When ``True``, each public method commits and
                closes its session automatically.  Useful for CLI commands
                that operate outside a ``UnitOfWork``.
        """
        self._session_factory = session_factory
        self._auto_commit = auto_commit

    def _session(self) -> Session:
        """Convenience helper to obtain a session."""
        return self._session_factory()

    @database_retry
    def create(self, skill: Skill) -> Skill:
        """Persist a new skill domain object.

        Args:
            skill: A ``Skill`` domain model instance.

        Returns:
            The skill after persistence.

        Raises:
            DuplicateSkillError: If a skill with the same name already exists.
            DatabaseError: On transient or unexpected DB errors.
        """
        session = self._session()
        try:
            db_model = SkillModel.from_domain(skill)
            session.add(db_model)
            session.flush()
            if self._auto_commit:
                session.commit()
                session.close()
            return skill
        except IntegrityError as exc:
            session.rollback()
            name_str = getattr(skill, "name", "")
            if "UNIQUE" in str(exc).upper() or "unique" in str(exc).lower():
                raise DuplicateSkillError(name_str) from exc
            raise DatabaseError(f"Failed to create skill: {exc}") from exc
        except DuplicateSkillError:
            raise
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            session.rollback()
            raise DatabaseError(f"Failed to create skill: {exc}") from exc

    @database_retry
    def get_by_name(self, name: str) -> Skill | None:
        """Retrieve a skill by its namespaced name (PK).

        Returns:
            The skill as a ``Skill`` domain object, or ``None`` if not found.
        """
        session = self._session()
        try:
            row = session.query(SkillModel).filter_by(name=name).first()
            if row is None:
                return None
            return row.to_domain()
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            raise DatabaseError(f"Failed to get skill {name}: {exc}") from exc

    @database_retry
    def list_all(
        self,
        namespace: str | None = None,
    ) -> list[Skill]:
        """List skills with optional namespace filter.

        Args:
            namespace: Optional namespace filter.

        Returns:
            List of ``Skill`` domain objects.
        """
        session = self._session()
        try:
            query = session.query(SkillModel)
            if namespace is not None:
                query = query.filter(SkillModel.namespace == namespace)
            rows = query.order_by(SkillModel.name).all()
            return [row.to_domain() for row in rows]
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            raise DatabaseError(f"Failed to list skills: {exc}") from exc

    @database_retry
    def update(self, skill: Skill) -> Skill:
        """Update all mutable fields of an existing skill.

        Replaces child items entirely.

        Args:
            skill: The ``Skill`` domain object with updated fields.

        Returns:
            The skill after persistence.

        Raises:
            SkillNotFoundError: If the skill is not found.
            DatabaseError: On transient or unexpected DB errors.
        """
        session = self._session()

        name_str: str = skill.name
        try:
            row = session.query(SkillModel).filter_by(name=name_str).first()
            if row is None:
                raise SkillNotFoundError(name_str)

            row.namespace = name_str.split("/", 1)[0]  # type: ignore[assignment]
            row.short_name = name_str.split("/", 1)[1]  # type: ignore[assignment]
            row.description = skill.description  # type: ignore[assignment]
            row.version = getattr(skill, "version", None)  # type: ignore[assignment]

            # Update metadata from overrides
            overrides = skill.overrides
            if overrides:
                row.metadata_json = json.dumps({"overrides": overrides})  # type: ignore[assignment]
            else:
                row.metadata_json = None  # type: ignore[assignment]

            row.updated_at = datetime.now(tz=UTC).isoformat()  # type: ignore[assignment]

            # Invalidate cached flattened tool data on update
            row.flattened_tools_json = None  # type: ignore[assignment]
            row.includes_json = None  # type: ignore[assignment]
            row.capability_summary_json = None  # type: ignore[assignment]
            row.flattening_hash = None  # type: ignore[assignment]

            # Replace child items via a fresh from_domain and swap items
            row.items_rel.clear()  # type: ignore[union-attr]
            new_model = SkillModel.from_domain(skill)
            for item in new_model.items_rel:
                row.items_rel.append(  # type: ignore[union-attr]
                    SkillItemModel(
                        item_type=item.item_type,
                        item_name=item.item_name,
                        item_config=item.item_config,
                        item_order=item.item_order,
                        created_at=item.created_at,
                    )
                )

            session.flush()
            if self._auto_commit:
                session.commit()
                session.close()
            return skill
        except SkillNotFoundError:
            raise
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            session.rollback()
            raise DatabaseError(f"Failed to update skill {name_str}: {exc}") from exc

    @database_retry
    def delete(self, name: str) -> bool:
        """Delete a skill by namespaced name.

        Cascades to child skill_items rows.

        Args:
            name: The namespaced name of the skill to delete.

        Returns:
            ``True`` if the skill was deleted, ``False`` if not found.

        Raises:
            DatabaseError: On transient or unexpected DB errors.
        """
        session = self._session()
        try:
            row = session.query(SkillModel).filter_by(name=name).first()
            if row is None:
                return False
            session.delete(row)
            session.flush()
            if self._auto_commit:
                session.commit()
                session.close()
            return True
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            session.rollback()
            raise DatabaseError(f"Failed to delete skill {name}: {exc}") from exc

    # -- Flattened tool cache methods (m4_002) ------------------------------

    @database_retry
    def update_flattened_tools(
        self,
        name: str,
        flattened_tools_json: str,
        includes_json: str,
        capability_summary_json: str,
        yaml_text: str,
        flattening_hash: str,
    ) -> None:
        """Persist a flattened tool set and associated cache data.

        Args:
            name: Namespaced skill name (PK).
            flattened_tools_json: JSON-serialized flattened tool descriptors.
            includes_json: JSON-serialized includes list.
            capability_summary_json: JSON-serialized capability summary.
            yaml_text: Original YAML text of the skill definition.
            flattening_hash: SHA-256 hash for refresh invalidation.

        Raises:
            SkillNotFoundError: If the skill does not exist.
            DatabaseError: On transient or unexpected DB errors.
        """
        session = self._session()
        try:
            row = session.query(SkillModel).filter_by(name=name).first()
            if row is None:
                raise SkillNotFoundError(name)
            row.flattened_tools_json = flattened_tools_json  # type: ignore[assignment]
            row.includes_json = includes_json  # type: ignore[assignment]
            row.capability_summary_json = capability_summary_json  # type: ignore[assignment]
            row.yaml_text = yaml_text  # type: ignore[assignment]
            row.flattening_hash = flattening_hash  # type: ignore[assignment]
            row.updated_at = datetime.now(tz=UTC).isoformat()  # type: ignore[assignment]
            session.flush()
            _log.info(
                "skill_flattened_tools_updated",
                skill_name=name,
                hash=flattening_hash,
            )
        except SkillNotFoundError:
            raise
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            session.rollback()
            raise DatabaseError(
                f"Failed to update flattened tools for {name}: {exc}",
            ) from exc

    @database_retry
    def get_flattened_tools(
        self,
        name: str,
    ) -> dict[str, str | None]:
        """Retrieve the stored flattened tool cache fields for a skill.

        Args:
            name: Namespaced skill name.

        Returns:
            Dict with keys ``flattened_tools_json``, ``includes_json``,
            ``capability_summary_json``, ``yaml_text``, ``flattening_hash``.
            Values are ``None`` when the cache has been invalidated.

        Raises:
            SkillNotFoundError: If the skill does not exist.
            DatabaseError: On transient or unexpected DB errors.
        """
        session = self._session()
        try:
            row = session.query(SkillModel).filter_by(name=name).first()
            if row is None:
                raise SkillNotFoundError(name)
            return {
                "flattened_tools_json": cast("str | None", row.flattened_tools_json),
                "includes_json": cast("str | None", row.includes_json),
                "capability_summary_json": cast(
                    "str | None", row.capability_summary_json
                ),
                "yaml_text": cast("str | None", row.yaml_text),
                "flattening_hash": cast("str | None", row.flattening_hash),
            }
        except SkillNotFoundError:
            raise
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            raise DatabaseError(
                f"Failed to get flattened tools for {name}: {exc}",
            ) from exc

    @database_retry
    def needs_refresh(self, name: str, current_hash: str) -> bool:
        """Check whether a skill's cached flattened data is stale.

        Compares the stored ``flattening_hash`` against *current_hash*.

        Args:
            name: Namespaced skill name.
            current_hash: SHA-256 hash of the current YAML text.

        Returns:
            ``True`` if the stored hash is missing or differs from
            *current_hash*; ``False`` if they match.

        Raises:
            SkillNotFoundError: If the skill does not exist.
            DatabaseError: On transient or unexpected DB errors.
        """
        session = self._session()
        try:
            row = session.query(SkillModel).filter_by(name=name).first()
            if row is None:
                raise SkillNotFoundError(name)
            stored_hash = cast("str | None", row.flattening_hash)
            if stored_hash is None:
                return True
            return stored_hash != current_hash
        except SkillNotFoundError:
            raise
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            raise DatabaseError(
                f"Failed to check refresh for {name}: {exc}",
            ) from exc

    @database_retry
    def recompute_flattening_hash(self, name: str, yaml_text: str) -> str:
        """Compute SHA-256 of *yaml_text* and store it on the skill row.

        Args:
            name: Namespaced skill name.
            yaml_text: The YAML text to hash.

        Returns:
            The computed SHA-256 hex digest.

        Raises:
            SkillNotFoundError: If the skill does not exist.
            DatabaseError: On transient or unexpected DB errors.
        """
        session = self._session()
        try:
            row = session.query(SkillModel).filter_by(name=name).first()
            if row is None:
                raise SkillNotFoundError(name)
            new_hash = hashlib.sha256(yaml_text.encode("utf-8")).hexdigest()
            row.flattening_hash = new_hash  # type: ignore[assignment]
            row.updated_at = datetime.now(tz=UTC).isoformat()  # type: ignore[assignment]
            session.flush()
            _log.info(
                "skill_flattening_hash_recomputed",
                skill_name=name,
                hash=new_hash,
            )
            return new_hash
        except SkillNotFoundError:
            raise
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            session.rollback()
            raise DatabaseError(
                f"Failed to recompute hash for {name}: {exc}",
            ) from exc

    @database_retry
    def invalidate_cached_summaries(self, name: str) -> None:
        """Null the cached flattened fields for a skill.

        Called when the skill definition changes and the cached
        flattened tool set is no longer valid.

        Args:
            name: Namespaced skill name.

        Raises:
            SkillNotFoundError: If the skill does not exist.
            DatabaseError: On transient or unexpected DB errors.
        """
        session = self._session()
        try:
            row = session.query(SkillModel).filter_by(name=name).first()
            if row is None:
                raise SkillNotFoundError(name)
            row.flattened_tools_json = None  # type: ignore[assignment]
            row.includes_json = None  # type: ignore[assignment]
            row.capability_summary_json = None  # type: ignore[assignment]
            row.flattening_hash = None  # type: ignore[assignment]
            row.updated_at = datetime.now(tz=UTC).isoformat()  # type: ignore[assignment]
            session.flush()
            _log.info("skill_cached_summaries_invalidated", skill_name=name)
        except SkillNotFoundError:
            raise
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            session.rollback()
            raise DatabaseError(
                f"Failed to invalidate cached summaries for {name}: {exc}",
            ) from exc


# ---------------------------------------------------------------------------
# Decision repository (Stage M3.2)
# ---------------------------------------------------------------------------


class DuplicateDecisionError(DatabaseError):
    """Raised when creating a decision with an ID that already exists."""

    def __init__(self, decision_id: str) -> None:
        super().__init__(f"Decision '{decision_id}' already exists")
        self.decision_id = decision_id


class DecisionNotFoundError(DatabaseError):
    """Raised when a decision cannot be found."""

    def __init__(self, decision_id: str) -> None:
        super().__init__(f"Decision '{decision_id}' not found")
        self.decision_id = decision_id


class DecisionRepository(DecisionRepositoryProtocol):
    """Repository for decision tree node persistence.

    Implements :class:`~cleveragents.domain.repositories.DecisionRepositoryProtocol`
    — the domain-layer port for decision tree node persistence.

    Uses the session-factory pattern (ADR-007).  All mutating methods
    flush but do **not** commit; the caller or a :class:`UnitOfWork`
    wrapper is responsible for committing the transaction.
    """

    def __init__(self, session_factory: Callable[[], Session]) -> None:
        """Initialise with a callable that returns a SQLAlchemy Session."""
        self._session_factory = session_factory

    def _session(self) -> Session:
        """Convenience helper to obtain a session."""
        return self._session_factory()

    # --- CREATE ------------------------------------------------------------

    @database_retry
    def create(self, decision: Decision) -> Decision:
        """Persist a new decision.

        Args:
            decision: Domain :class:`Decision` instance.

        Returns:
            The same domain decision (pass-through for fluent API).

        Raises:
            DuplicateDecisionError: If the ``decision_id`` already exists.
            DatabaseError: On transient or unexpected DB errors.
        """
        session = self._session()
        try:
            db_model = DecisionModel.from_domain(decision)
            session.add(db_model)
            session.flush()
            return decision
        except IntegrityError as exc:
            session.rollback()
            if "UNIQUE" in str(exc).upper() or "unique" in str(exc).lower():
                raise DuplicateDecisionError(
                    str(decision.decision_id),
                ) from exc
            raise DatabaseError(
                f"Failed to create decision: {exc}",
            ) from exc
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            session.rollback()
            raise DatabaseError(
                f"Failed to create decision: {exc}",
            ) from exc

    # --- GET ---------------------------------------------------------------

    @database_retry
    def get(self, decision_id: str) -> Decision | None:
        """Retrieve a single decision by ID.

        Args:
            decision_id: ULID of the decision.

        Returns:
            Domain :class:`Decision` or ``None`` if not found.

        Raises:
            DatabaseError: On transient or unexpected DB errors.
        """
        session = self._session()
        try:
            row = (
                session.query(DecisionModel).filter_by(decision_id=decision_id).first()
            )
            if row is None:
                return None
            return row.to_domain()
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            raise DatabaseError(
                f"Failed to get decision {decision_id}: {exc}",
            ) from exc

    # --- GET BY PLAN -------------------------------------------------------

    @database_retry
    def get_by_plan(self, plan_id: str) -> list[Decision]:
        """Retrieve all decisions for a plan, ordered by sequence number.

        Args:
            plan_id: ULID of the plan.

        Returns:
            List of domain :class:`Decision` instances.

        Raises:
            DatabaseError: On transient or unexpected DB errors.
        """
        session = self._session()
        try:
            rows = (
                session.query(DecisionModel)
                .filter_by(plan_id=plan_id)
                .order_by(DecisionModel.sequence_number)
                .all()
            )
            return [r.to_domain() for r in rows]
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            raise DatabaseError(
                f"Failed to get decisions for plan {plan_id}: {exc}",
            ) from exc

    # --- GET TREE ----------------------------------------------------------

    @database_retry
    def get_tree(self, root_decision_id: str) -> list[Decision]:
        """Retrieve the full decision tree rooted at *root_decision_id*.

        Performs a breadth-first walk using the ``parent_decision_id``
        relationship.  Returns the root followed by its descendants in
        BFS order.

        Args:
            root_decision_id: ULID of the root decision.

        Returns:
            List of domain :class:`Decision` instances (BFS order).

        Raises:
            DecisionNotFoundError: If the root decision does not exist.
            DatabaseError: On transient or unexpected DB errors.
        """
        session = self._session()
        try:
            # Fetch root
            root_row = (
                session.query(DecisionModel)
                .filter_by(decision_id=root_decision_id)
                .first()
            )
            if root_row is None:
                raise DecisionNotFoundError(root_decision_id)

            # BFS walk
            result: list[Decision] = [root_row.to_domain()]
            bfs_queue: deque[str] = deque([root_decision_id])
            while bfs_queue:
                parent_id = bfs_queue.popleft()
                children = (
                    session.query(DecisionModel)
                    .filter_by(parent_decision_id=parent_id)
                    .order_by(DecisionModel.sequence_number)
                    .all()
                )
                for child in children:
                    result.append(child.to_domain())
                    bfs_queue.append(cast(str, child.decision_id))
            return result
        except DecisionNotFoundError:
            raise
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            raise DatabaseError(
                f"Failed to get decision tree {root_decision_id}: {exc}",
            ) from exc

    # --- GET PATH TO ROOT --------------------------------------------------

    @database_retry
    def get_path_to_root(self, decision_id: str) -> list[Decision]:
        """Walk from *decision_id* up to the tree root.

        Returns a list starting with the given decision and ending
        with the root (``parent_decision_id is None``).

        Args:
            decision_id: ULID of the starting decision.

        Returns:
            List of domain :class:`Decision` instances (leaf → root).

        Raises:
            DecisionNotFoundError: If the starting decision is missing.
            DatabaseError: On transient or unexpected DB errors.
        """
        session = self._session()
        try:
            path: list[Decision] = []
            current_id: str | None = decision_id
            visited: set[str] = set()
            while current_id is not None:
                if current_id in visited:
                    break  # safety: avoid cycles
                visited.add(current_id)
                row = (
                    session.query(DecisionModel)
                    .filter_by(decision_id=current_id)
                    .first()
                )
                if row is None:
                    if not path:
                        raise DecisionNotFoundError(decision_id)
                    break
                path.append(row.to_domain())
                current_id = cast(str | None, row.parent_decision_id)
            return path
        except DecisionNotFoundError:
            raise
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            raise DatabaseError(
                f"Failed to get path to root from {decision_id}: {exc}",
            ) from exc

    # --- GET SUPERSEDED ----------------------------------------------------

    @database_retry
    def get_superseded(self, plan_id: str) -> list[Decision]:
        """Retrieve all superseded decisions for a plan.

        Args:
            plan_id: ULID of the plan.

        Returns:
            List of domain :class:`Decision` instances that have been
            superseded (``superseded_by IS NOT NULL``).

        Raises:
            DatabaseError: On transient or unexpected DB errors.
        """
        session = self._session()
        try:
            rows = (
                session.query(DecisionModel)
                .filter(
                    DecisionModel.plan_id == plan_id,
                    DecisionModel.superseded_by.isnot(None),
                )
                .order_by(DecisionModel.sequence_number)
                .all()
            )
            return [r.to_domain() for r in rows]
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            raise DatabaseError(
                f"Failed to get superseded decisions for plan {plan_id}: {exc}",
            ) from exc

    # --- UPDATE SUPERSEDED BY ----------------------------------------------

    @database_retry
    def update_superseded_by(
        self,
        decision_id: str,
        new_decision_id: str,
    ) -> Decision:
        """Mark a decision as superseded by another.

        Args:
            decision_id: ULID of the decision to supersede.
            new_decision_id: ULID of the replacement decision.

        Returns:
            Updated domain :class:`Decision`.

        Raises:
            DecisionNotFoundError: If *decision_id* does not exist.
            DatabaseError: On transient or unexpected DB errors.
        """
        session = self._session()
        try:
            row = (
                session.query(DecisionModel).filter_by(decision_id=decision_id).first()
            )
            if row is None:
                raise DecisionNotFoundError(decision_id)
            row.superseded_by = new_decision_id  # type: ignore[assignment]
            session.flush()
            return row.to_domain()
        except DecisionNotFoundError:
            raise
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            session.rollback()
            raise DatabaseError(
                f"Failed to update superseded_by for {decision_id}: {exc}",
            ) from exc

    # --- LIST BY TYPE ------------------------------------------------------

    @database_retry
    def list_by_type(
        self,
        plan_id: str,
        decision_type: str,
    ) -> list[Decision]:
        """Retrieve decisions of a specific type for a plan.

        Args:
            plan_id: ULID of the plan.
            decision_type: String value of the :class:`DecisionType` enum.

        Returns:
            List of domain :class:`Decision` instances.

        Raises:
            DatabaseError: On transient or unexpected DB errors.
        """
        session = self._session()
        try:
            rows = (
                session.query(DecisionModel)
                .filter(
                    DecisionModel.plan_id == plan_id,
                    DecisionModel.decision_type == decision_type,
                )
                .order_by(DecisionModel.sequence_number)
                .all()
            )
            return [r.to_domain() for r in rows]
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            raise DatabaseError(
                f"Failed to list decisions by type {decision_type}: {exc}",
            ) from exc

    # --- DELETE -------------------------------------------------------------

    @database_retry
    def delete(self, decision_id: str) -> bool:
        """Delete a decision by ID.

        Args:
            decision_id: ULID of the decision to delete.

        Returns:
            ``True`` if the decision was deleted, ``False`` if not found.

        Raises:
            DatabaseError: On transient or unexpected DB errors.
        """
        session = self._session()
        try:
            row = (
                session.query(DecisionModel).filter_by(decision_id=decision_id).first()
            )
            if row is None:
                return False
            session.delete(row)
            session.flush()
            return True
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            session.rollback()
            raise DatabaseError(
                f"Failed to delete decision {decision_id}: {exc}",
            ) from exc

    # --- SEQUENCE ----------------------------------------------------------

    @database_retry
    def get_max_sequence_number(self, plan_id: str) -> int | None:
        """Return the highest sequence number for *plan_id*, or ``None``.

        Uses the composite index ``ix_decisions_plan_seq`` for an
        efficient ``MAX()`` aggregate.

        Args:
            plan_id: ULID of the plan.

        Returns:
            The maximum ``sequence_number`` stored for the plan, or
            ``None`` when no decisions exist for the plan.

        Raises:
            DatabaseError: On transient or unexpected DB errors.
        """
        session = self._session()
        try:
            result: int | None = (
                session.query(sa_func.max(DecisionModel.sequence_number))
                .filter(DecisionModel.plan_id == plan_id)
                .scalar()
            )
            return result
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            raise DatabaseError(
                f"Failed to get max sequence number for plan {plan_id}: {exc}",
            ) from exc

    # --- COUNT -------------------------------------------------------------

    @database_retry
    def count(self, plan_id: str) -> int:
        """Return the number of decisions for *plan_id*.

        Uses ``COUNT(*)`` for efficiency instead of loading all rows.

        Args:
            plan_id: ULID of the plan.

        Returns:
            The decision count (``0`` when no decisions exist).

        Raises:
            DatabaseError: On transient or unexpected DB errors.
        """
        session = self._session()
        try:
            result: int = (
                session.query(sa_func.count(DecisionModel.decision_id))
                .filter(DecisionModel.plan_id == plan_id)
                .scalar()
            ) or 0
            return result
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            raise DatabaseError(
                f"Failed to count decisions for plan {plan_id}: {exc}",
            ) from exc


# ---------------------------------------------------------------------------
# Checkpoint Repository (Stage M6 - checkpointing and rollback)
# ---------------------------------------------------------------------------


class CheckpointNotFoundError(DatabaseError):
    """Raised when a checkpoint ID is not found."""

    def __init__(self, checkpoint_id: str) -> None:
        super().__init__(f"Checkpoint not found: {checkpoint_id}")
        self.checkpoint_id = checkpoint_id


class CheckpointRepository:
    """CRUD operations for the ``checkpoint_metadata`` table.

    Follows the session-factory pattern (ADR-007) consistent with
    other repositories in this module.
    """

    def __init__(self, session_factory: Callable[[], Session]) -> None:
        self._session_factory = session_factory

    @database_retry
    def create(self, checkpoint: Checkpoint) -> Checkpoint:
        """Persist a new checkpoint.

        Args:
            checkpoint: A ``Checkpoint`` domain instance.

        Returns:
            The persisted ``Checkpoint`` domain object.

        Raises:
            DatabaseError: On duplicate key or transient DB errors.
        """
        session = self._session_factory()
        try:
            model = CheckpointModel.from_domain(checkpoint)
            session.add(model)
            session.flush()
            return model.to_domain()
        except IntegrityError as exc:
            session.rollback()
            raise DatabaseError(
                f"Duplicate or constraint violation creating checkpoint: {exc}"
            ) from exc
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            session.rollback()
            raise DatabaseError(f"Failed to create checkpoint: {exc}") from exc

    @database_retry
    def get_by_id(self, checkpoint_id: str) -> Checkpoint:
        """Retrieve a checkpoint by its ULID.

        Args:
            checkpoint_id: The checkpoint ULID.

        Returns:
            The ``Checkpoint`` domain object.

        Raises:
            CheckpointNotFoundError: If not found.
            DatabaseError: On transient or unexpected DB errors.
        """
        session = self._session_factory()
        try:
            row = (
                session.query(CheckpointModel)
                .filter_by(checkpoint_id=checkpoint_id)
                .first()
            )
            if row is None:
                raise CheckpointNotFoundError(checkpoint_id)
            return row.to_domain()
        except CheckpointNotFoundError:
            raise
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            session.rollback()
            raise DatabaseError(
                f"Failed to get checkpoint {checkpoint_id}: {exc}"
            ) from exc

    @database_retry
    def list_by_plan(self, plan_id: str) -> list[Checkpoint]:
        """List all checkpoints for a plan, ordered by creation time.

        Args:
            plan_id: Plan ULID.

        Returns:
            List of ``Checkpoint`` domain objects.

        Raises:
            DatabaseError: On transient or unexpected DB errors.
        """
        session = self._session_factory()
        try:
            rows = (
                session.query(CheckpointModel)
                .filter_by(plan_id=plan_id)
                .order_by(CheckpointModel.created_at)
                .all()
            )
            return [row.to_domain() for row in rows]
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            session.rollback()
            raise DatabaseError(
                f"Failed to list checkpoints for plan {plan_id}: {exc}"
            ) from exc

    @database_retry
    def delete(self, checkpoint_id: str) -> bool:
        """Delete a checkpoint by its ULID.

        Args:
            checkpoint_id: The checkpoint to delete.

        Returns:
            ``True`` if deleted, ``False`` if not found.

        Raises:
            DatabaseError: On transient or unexpected DB errors.
        """
        session = self._session_factory()
        try:
            row = (
                session.query(CheckpointModel)
                .filter_by(checkpoint_id=checkpoint_id)
                .first()
            )
            if row is None:
                return False
            session.delete(row)
            session.flush()
            return True
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            session.rollback()
            raise DatabaseError(
                f"Failed to delete checkpoint {checkpoint_id}: {exc}"
            ) from exc

    @database_retry
    def prune(self, plan_id: str, max_checkpoints: int) -> list[str]:
        """Remove oldest checkpoints exceeding the retention limit.

        Preserves the first (earliest) and most recent checkpoints.
        Only interior checkpoints are eligible for removal.

        Args:
            plan_id: Plan to prune checkpoints for.
            max_checkpoints: Maximum allowed checkpoints.

        Returns:
            List of pruned checkpoint IDs.

        Raises:
            DatabaseError: On transient or unexpected DB errors.
        """
        session = self._session_factory()
        try:
            rows = (
                session.query(CheckpointModel)
                .filter_by(plan_id=plan_id)
                .order_by(CheckpointModel.created_at)
                .all()
            )
            if len(rows) <= max_checkpoints or len(rows) < 3:
                return []

            # Preserve first (oldest) and last (most recent);
            # only interior rows (indices 1..-2) are eligible for removal.
            interior = rows[1:-1]

            # Number of rows to remove from interior
            excess = len(rows) - max_checkpoints
            to_remove = interior[:excess]

            pruned_ids: list[str] = []
            for row in to_remove:
                pruned_ids.append(cast(str, row.checkpoint_id))
                session.delete(row)

            # Edge case: if after interior pruning we are still over,
            # the first/last constraint means we cannot prune further.
            # This can happen when max_checkpoints < 2, but the model
            # enforces ge=1 so at worst we keep 2.

            if pruned_ids:
                session.flush()
            return pruned_ids
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            session.rollback()
            raise DatabaseError(
                f"Failed to prune checkpoints for plan {plan_id}: {exc}"
            ) from exc


# ---------------------------------------------------------------------------
# Correction Attempt Repository
# ---------------------------------------------------------------------------


class CorrectionAttemptNotFoundError(DatabaseError):
    """Raised when a correction attempt record is not found."""


class DuplicateCorrectionAttemptError(DatabaseError):
    """Raised when a correction attempt ID already exists."""


class InvalidCorrectionStateTransitionError(BusinessRuleViolation):
    """Raised when an invalid state transition is attempted."""


class CorrectionAttemptRepository:
    """CRUD operations for the ``correction_attempts`` table.

    Follows the session-factory pattern (ADR-007) consistent with
    other repositories in this module.  State transition validation
    is delegated to the domain-level
    ``validate_correction_state_transition`` function.
    """

    def __init__(self, session_factory: Callable[[], Session]) -> None:
        """Initialise with a callable that returns a SQLAlchemy Session."""
        self._session_factory = session_factory

    def _session(self) -> Session:
        """Convenience helper to obtain a session."""
        return self._session_factory()

    # --- CREATE ------------------------------------------------------------

    @database_retry
    def create(self, record: CorrectionAttemptRecord) -> CorrectionAttemptRecord:
        """Persist a new correction attempt record.

        Args:
            record: A ``CorrectionAttemptRecord`` domain instance.

        Returns:
            The persisted ``CorrectionAttemptRecord`` domain object.

        Raises:
            DuplicateCorrectionAttemptError: If the ID already exists.
            DatabaseError: On transient or unexpected DB errors.
        """
        session = self._session()
        try:
            model = CorrectionAttemptModel.from_domain(record)
            session.add(model)
            session.flush()
            return model.to_domain()
        except IntegrityError as exc:
            session.rollback()
            exc_str = str(exc).upper()
            if "UNIQUE" in exc_str:
                raise DuplicateCorrectionAttemptError(
                    f"Correction attempt already exists: {record.correction_attempt_id}"
                ) from exc
            if "FOREIGN KEY" in exc_str or "FOREIGN_KEY" in exc_str:
                raise DatabaseError(
                    f"Foreign key constraint violated creating correction "
                    f"attempt {record.correction_attempt_id}: verify that "
                    f"plan_id '{record.plan_id}' and decision IDs exist"
                ) from exc
            raise DatabaseError(f"Failed to create correction attempt: {exc}") from exc
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            session.rollback()
            raise DatabaseError(f"Failed to create correction attempt: {exc}") from exc

    # --- GET ---------------------------------------------------------------

    @database_retry
    def get(self, correction_attempt_id: str) -> CorrectionAttemptRecord:
        """Retrieve a correction attempt by ID.

        Args:
            correction_attempt_id: ULID of the correction attempt.

        Returns:
            A ``CorrectionAttemptRecord`` domain instance.

        Raises:
            CorrectionAttemptNotFoundError: If not found.
            DatabaseError: On transient or unexpected DB errors.
        """
        session = self._session()
        try:
            row = (
                session.query(CorrectionAttemptModel)
                .filter_by(correction_attempt_id=correction_attempt_id)
                .first()
            )
            if row is None:
                raise CorrectionAttemptNotFoundError(
                    f"Correction attempt not found: {correction_attempt_id}"
                )
            return row.to_domain()
        except CorrectionAttemptNotFoundError:
            raise
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            raise DatabaseError(
                f"Failed to get correction attempt {correction_attempt_id}: {exc}"
            ) from exc

    # --- LIST BY PLAN ------------------------------------------------------

    @database_retry
    def list_by_plan(self, plan_id: str) -> list[CorrectionAttemptRecord]:
        """List all correction attempts for a plan, ordered by creation time.

        Args:
            plan_id: Plan ULID.

        Returns:
            List of ``CorrectionAttemptRecord`` domain objects.

        Raises:
            DatabaseError: On transient or unexpected DB errors.
        """
        session = self._session()
        try:
            rows = (
                session.query(CorrectionAttemptModel)
                .filter_by(plan_id=plan_id)
                .order_by(CorrectionAttemptModel.created_at)
                .all()
            )
            return [row.to_domain() for row in rows]
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            raise DatabaseError(
                f"Failed to list correction attempts for plan {plan_id}: {exc}"
            ) from exc

    # --- UPDATE STATE ------------------------------------------------------

    @database_retry
    def update_state(
        self,
        correction_attempt_id: str,
        state: CorrectionAttemptState,
        completed_at: datetime | None = None,
        new_decision_id: str | None = None,
        archived_artifacts_path: str | None = None,
    ) -> CorrectionAttemptRecord:
        """Update the state and optional fields of a correction attempt.

        Args:
            correction_attempt_id: ULID of the correction attempt.
            state: New lifecycle state (``CorrectionAttemptState`` enum).
            completed_at: Optional completion timestamp.
            new_decision_id: Optional new decision ID (for append mode).
            archived_artifacts_path: Optional archived artifacts path.

        Returns:
            The updated ``CorrectionAttemptRecord`` domain instance.

        Raises:
            CorrectionAttemptNotFoundError: If not found.
            InvalidCorrectionStateTransitionError: If the state transition
                is not allowed or ``completed_at`` is set on a non-terminal
                transition.
            DatabaseError: On input validation failure, FK violation, or
                transient/unexpected DB errors.
        """
        # -- Argument validation (before any session/row mutation) ----------
        # Per CONTRIBUTING.md: validate arguments as the first guard.
        # Use ValueError per CONTRIBUTING.md guidelines for invalid
        # values/ranges/empty inputs.
        stripped_decision_id: str | None = None
        if new_decision_id is not None:
            stripped_decision_id = new_decision_id.strip()
            if not stripped_decision_id:
                raise ValueError("new_decision_id must not be empty or whitespace-only")
        stripped_artifacts_path: str | None = None
        if archived_artifacts_path is not None:
            stripped_artifacts_path = archived_artifacts_path.strip()
            if not stripped_artifacts_path:
                raise ValueError(
                    "archived_artifacts_path must not be empty or whitespace-only"
                )

        session = self._session()
        try:
            row = (
                session.query(CorrectionAttemptModel)
                .filter_by(correction_attempt_id=correction_attempt_id)
                .first()
            )
            if row is None:
                raise CorrectionAttemptNotFoundError(
                    f"Correction attempt not found: {correction_attempt_id}"
                )
            try:
                current_state = CorrectionAttemptState(cast(str, row.state))
            except ValueError:
                _log.warning(
                    "Corrupted state '%s' for correction attempt %s; "
                    "treating as terminal 'failed'",
                    row.state,
                    correction_attempt_id,
                )
                current_state = CorrectionAttemptState.FAILED
            try:
                validate_correction_state_transition(current_state, state)
            except ValueError as exc:
                raise InvalidCorrectionStateTransitionError(
                    f"{exc} for correction attempt {correction_attempt_id}"
                ) from exc

            if (
                completed_at is not None
                and state not in CORRECTION_ATTEMPT_TERMINAL_STATES
            ):
                raise InvalidCorrectionStateTransitionError(
                    f"completed_at can only be set for terminal states "
                    f"(complete, failed), not '{state.value}'"
                )

            # -- Apply mutations (all validation passed) -------------------
            row.state = state.value  # type: ignore[assignment]
            # Auto-set completed_at for terminal transitions if not provided
            effective_completed_at = completed_at
            if (
                effective_completed_at is None
                and state in CORRECTION_ATTEMPT_TERMINAL_STATES
            ):
                effective_completed_at = datetime.now(UTC)
            if effective_completed_at is not None:
                row.completed_at = format_sqlite_timestamp(  # type: ignore[assignment]
                    effective_completed_at,
                )
            if stripped_decision_id is not None:
                row.new_decision_id = stripped_decision_id  # type: ignore[assignment]
            if stripped_artifacts_path is not None:
                row.archived_artifacts_path = stripped_artifacts_path  # type: ignore[assignment]
            session.flush()
            return row.to_domain()
        except (CorrectionAttemptNotFoundError, InvalidCorrectionStateTransitionError):
            raise
        except IntegrityError as exc:
            session.rollback()
            exc_str = str(exc).upper()
            if "FOREIGN KEY" in exc_str or "FOREIGN_KEY" in exc_str:
                fk_details = (
                    f"new_decision_id='{new_decision_id}'"
                    if new_decision_id is not None
                    else "referenced entity"
                )
                raise DatabaseError(
                    f"Foreign key constraint violated updating correction "
                    f"attempt {correction_attempt_id}: verify that "
                    f"{fk_details} exists"
                ) from exc
            raise DatabaseError(
                f"Failed to update correction attempt {correction_attempt_id}: {exc}"
            ) from exc
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            session.rollback()
            raise DatabaseError(
                f"Failed to update correction attempt {correction_attempt_id}: {exc}"
            ) from exc

    # --- DELETE -------------------------------------------------------------

    @database_retry
    def delete(self, correction_attempt_id: str) -> bool:
        """Delete a correction attempt by ID.

        Args:
            correction_attempt_id: ULID of the correction attempt.

        Returns:
            ``True`` if deleted, ``False`` if not found.

        Raises:
            DatabaseError: On transient or unexpected DB errors.
        """
        session = self._session()
        try:
            row = (
                session.query(CorrectionAttemptModel)
                .filter_by(correction_attempt_id=correction_attempt_id)
                .first()
            )
            if row is None:
                return False
            session.delete(row)
            session.flush()
            return True
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            session.rollback()
            raise DatabaseError(
                f"Failed to delete correction attempt {correction_attempt_id}: {exc}"
            ) from exc
