diff --git a/.cursor/rules/aegis-architecture.md b/.cursor/rules/aegis-architecture.md deleted file mode 100644 index f1c5fcf..0000000 --- a/.cursor/rules/aegis-architecture.md +++ /dev/null @@ -1,189 +0,0 @@ ---- -description: Aegis backend Clean Architecture rules. Apply when working on any backend Python file under backend/app/ or backend/tests/. -globs: backend/**/*.py ---- - -# Aegis — Clean Modular Monolith Architecture - -## Architecture Overview - -Aegis follows a **Clean Architecture** pattern inside a modular monolith. The backend has four layers with strict dependency rules: - -``` -Presentation → Application → Domain ← Infrastructure -``` - -**The golden rule:** dependencies only point towards the Domain layer. Infrastructure implements the ports (interfaces) defined in Domain. - -## Layer Structure and Rules - -### Domain Layer (`backend/app/domain/`) - -The innermost layer. **ZERO** imports from FastAPI, SQLAlchemy, Pydantic, or any framework. - -| Directory | Purpose | -|-----------|---------| -| `domain/enums.py` | Canonical domain enums (TechniqueStatus, TestState, TeamSide, TestResult) | -| `domain/errors.py` | Exception hierarchy (DomainError → EntityNotFoundError, InvalidStateTransition, etc.) | -| `domain/exceptions.py` | Backward-compatible re-exports from errors.py | -| `domain/test_entity.py` | TestEntity — pure state machine with domain events | -| `domain/entities/` | Rich domain entities (TechniqueEntity, etc.) with business behavior | -| `domain/value_objects/` | Immutable value types (MitreId, ScoringWeights) | -| `domain/ports/repositories/` | Protocol interfaces defining data access contracts | -| `domain/ports/services/` | Protocol interfaces for external capabilities (storage, events) | -| `domain/unit_of_work.py` | UnitOfWork wrapping SQLAlchemy session | - -**NEVER** import from `app.models`, `app.routers`, `app.infrastructure`, `fastapi`, or `sqlalchemy` inside `domain/`. - -### Application Layer (`backend/app/application/` — future) - -Use case orchestrators. Depends only on Domain. - -| Directory | Purpose | -|-----------|---------| -| `application/use_cases/` | One class per business operation | -| `application/dto/` | Plain data containers for use case input/output | -| `application/interfaces/` | Application-level contracts (UnitOfWork protocol) | - -### Infrastructure Layer (`backend/app/infrastructure/`) - -Implements ports defined in Domain. Depends on Domain and Application. - -| Directory | Purpose | -|-----------|---------| -| `infrastructure/redis_client.py` | Redis connection singleton | -| `infrastructure/persistence/repositories/` | SQLAlchemy implementations of repository ports | -| `infrastructure/persistence/mappers/` | ORM model ↔ domain entity converters | - -### Presentation Layer (routers, schemas, dependencies) - -HTTP boundary. Depends on Application and Domain (for exceptions). - -| Directory | Purpose | -|-----------|---------| -| `routers/` | FastAPI routers — HTTP mapping only | -| `schemas/` | Pydantic request/response models | -| `dependencies/` | FastAPI `Depends()` wiring (auth, repositories) | -| `middleware/` | Error handler mapping domain exceptions → HTTP responses | - -## Import Rules (Strict) - -| From \ To | domain/ | application/ | infrastructure/ | presentation/ | -|-----------|---------|-------------|----------------|--------------| -| **domain/** | Self only | FORBIDDEN | FORBIDDEN | FORBIDDEN | -| **application/** | ALLOWED | Self only | FORBIDDEN | FORBIDDEN | -| **infrastructure/** | ALLOWED (ports) | ALLOWED (UoW) | Self only | FORBIDDEN | -| **presentation/** | ALLOWED (exceptions) | ALLOWED (use cases) | ALLOWED (wiring in dependencies/) | Self only | - -## How to Add a New Feature - -### 1. Start from the Domain - -- Define or reuse domain entities in `domain/entities/` -- Add value objects if needed in `domain/value_objects/` -- Define repository port if a new aggregate root in `domain/ports/repositories/` -- Domain exceptions go in `domain/errors.py` -- Business rules live IN the entity, not in services or routers - -### 2. Implement Infrastructure - -- Create SQLAlchemy repository implementation in `infrastructure/persistence/repositories/` -- Create mapper if converting between ORM model and domain entity -- Repository does NOT call `commit()` — only `flush()` -- Transaction control belongs to the Unit of Work - -### 3. Wire in Presentation - -- Add FastAPI `Depends()` provider in `dependencies/repositories.py` -- Keep routers thin: parse request → call service/use case → return response -- Map domain exceptions to HTTP via the error handler middleware (automatic) - -### 4. Tests (Mandatory) - -Every change MUST include tests: -- **Domain entities/value objects**: pure unit tests, no DB, no mocking frameworks -- **Repositories**: integration tests using the `db` fixture from conftest -- **Routers**: API tests using the `client` fixture -- At least one success test + one failure/edge-case test per behavior - -Before committing, run: `scripts/agent_validate_backend.sh` - -## Existing Patterns to Follow - -### Domain Entity Pattern (see `domain/test_entity.py`) - -```python -@dataclass -class SomeEntity: - id: uuid.UUID - # fields... - _events: list[DomainEvent] = field(default_factory=list, repr=False) - - @classmethod - def from_orm(cls, model: Any) -> "SomeEntity": - """Build from SQLAlchemy model.""" - ... - - def apply_to(self, model: Any) -> None: - """Copy mutable fields back onto the ORM model.""" - ... - - def some_business_method(self) -> None: - """Business logic lives HERE, not in services.""" - ... - self._events.append(DomainEvent("something_happened")) -``` - -### Repository Port Pattern (Protocol) - -```python -from typing import Protocol, runtime_checkable - -@runtime_checkable -class SomeRepository(Protocol): - def find_by_id(self, id: uuid.UUID) -> SomeEntity | None: ... - def save(self, entity: SomeEntity) -> SomeEntity: ... -``` - -### Repository Implementation Pattern - -```python -class SASomeRepository: - def __init__(self, session: Session) -> None: - self._session = session - - def find_by_id(self, id: uuid.UUID) -> SomeEntity | None: - model = self._session.query(SomeModel).filter(SomeModel.id == id).first() - return SomeMapper.to_entity(model) if model else None - - def save(self, entity: SomeEntity) -> SomeEntity: - model = SomeMapper.to_model(entity) - merged = self._session.merge(model) - self._session.flush() # NO commit — UoW does that - return SomeMapper.to_entity(merged) -``` - -### Error Handling (automatic via middleware) - -Services raise domain exceptions → middleware maps to HTTP: -- `EntityNotFoundError` → 404 -- `DuplicateEntityError` → 409 -- `InvalidStateTransition` → 400 -- `BusinessRuleViolation` → 400 -- `PermissionViolation` → 403 - -### Coexistence Strategy - -Old code (direct `db.query()` in routers) and new code (repositories) coexist. Migration is incremental: -1. New endpoints use repositories -2. Existing endpoints are migrated one at a time -3. Both access the same DB, same session, same tables - -## Key Conventions - -- **Enums**: canonical source is `domain/enums.py`, `models/enums.py` re-exports -- **Exceptions**: raise from `domain/errors.py`, never raise `HTTPException` from services -- **Commits**: only via `UnitOfWork.commit()` or at the router level, never inside services/repos -- **IDs**: UUID everywhere (primary keys, foreign keys) -- **Tests**: SQLite in-memory for unit/integration, PostgreSQL in CI -- **Validation**: Pydantic in schemas (presentation), domain rules in entities (domain) diff --git a/.gitignore b/.gitignore index e71bbca..03a9b56 100644 --- a/.gitignore +++ b/.gitignore @@ -65,6 +65,7 @@ Thumbs.db docs/confluence/ docs/drafts/ -# Claude working files — contain credentials, never commit +# Editor / AI assistant working files — never commit .claude/ +.cursor/ CLAUDE.md