# Phase 2: Data Layer — Schema and Models

## Goal

Build the complete data persistence layer: Pydantic domain models for all MVP entities, SQLite schema with forward-compatible v1.1 fields, and a repository layer that provides typed CRUD operations. After this phase, later phases (static analysis, LLM pipeline, campaign orchestration) can persist and query all campaign data through a clean Python API.

## Dependencies

- **Phase 1** — package structure, `constants.py` enums, `config.py` (campaign dir, config loading)

## Research Notes

### Pydantic v2 patterns used
- `BaseModel` with `Field()` for defaults, constraints, and descriptions
- `model_validate()` for constructing from dicts (database row mapping)
- `Optional[T] = None` for nullable fields (line_start, line_end, standard_tag, etc.)
- `field_validator` for domain rules (e.g., time_estimate derived from difficulty)
- No ORM — models are plain data containers. The repository layer maps between models and SQLite rows manually

### SQLite patterns
- Single-file database per campaign, stored in the campaign directory (`~/.unfamiliar/campaigns/<name>/campaign.db`)
- `sqlite3` from stdlib — no SQLAlchemy, no ORM. Direct SQL with parameterized queries
- WAL mode for concurrent reads during TUI sessions
- Foreign keys enabled via `PRAGMA foreign_keys = ON`
- Schema version tracked in a `meta` table for upgrade detection
- `created_at` and `updated_at` timestamps as ISO 8601 text (SQLite has no native datetime)
- JSON columns for list/dict fields (category_relevance on Module, tech_stack on Briefing)

### Forward compatibility
- v1.1 fields (`mode`, `character`) included in schema with defaults — avoids ALTER TABLE later
- Entry Point, Dependency, and Module-Dependency tables are NOT created in MVP (they're new tables in v1.1, purely additive)
- `schema_version` in meta table enables upgrade detection

### Security
- All queries use parameterized statements (no string interpolation)
- Database file permissions set to 600 on creation
- No user-supplied SQL — repository layer is the only database interface

---

## Tasks

### Task 1: Pydantic domain models

**Files:** `src/unfamiliar/models/__init__.py`, `src/unfamiliar/models/campaign.py`, `src/unfamiliar/models/mission.py`, `src/unfamiliar/models/analysis.py`

**Action:** Create Pydantic v2 models for all MVP entities. Split across three modules by domain area:

- `campaign.py` — `Campaign`, `Briefing`
- `mission.py` — `Mission`, `MissionChain`, `MissionFileRef`, `MissionPrerequisite`, `ChainMissionLink`, `ModuleMissionLink`
- `analysis.py` — `Module`, `FileSnapshot`

Each model includes:
- All domain fields from requirements Section 4
- `id: int | None = None` (None before database insert)
- `created_at` and `updated_at` as `datetime | None = None`
- Foreign key references as plain int fields (e.g., `campaign_id: int`)
- Enum fields use the StrEnum types from `constants.py`
- v1.1 fields included with defaults (Campaign.mode, Campaign.character)

Re-export all models from `models/__init__.py`.

**Verify:** `python -c "from unfamiliar.models import Campaign, Mission, Module, Briefing, MissionChain, FileSnapshot"`; `ruff check src/unfamiliar/models/`; `mypy src/unfamiliar/models/`

**Done when:** All MVP entity models importable, pass type checking, and match the field list in requirements Section 4.

---

### Task 2: SQLite schema definition

**Files:** `src/unfamiliar/db/schema.py`

**Action:** Define the complete DDL as a Python string constant `SCHEMA_SQL`. Tables:

| Table | Key columns |
|-------|------------|
| `meta` | `key TEXT PRIMARY KEY, value TEXT` |
| `campaign` | All Campaign fields + id, timestamps |
| `briefing` | All Briefing fields + id, campaign_id FK, timestamps |
| `module` | All Module fields + id, campaign_id FK, timestamps. `category_relevance` as JSON text |
| `mission` | All Mission fields + id, campaign_id FK, timestamps |
| `mission_file_ref` | mission_id FK, file_path, line_start, line_end, description, file_hash |
| `mission_prerequisite` | prerequisite_id FK, dependent_id FK, composite PK |
| `module_mission` | module_id FK, mission_id FK, is_primary, composite PK |
| `mission_chain` | All MissionChain fields + id, campaign_id FK, timestamps |
| `chain_mission` | chain_id FK, mission_id FK, position, composite PK |
| `file_snapshot` | All FileSnapshot fields + id, campaign_id FK, timestamps |

Include indexes on foreign keys and common query patterns (e.g., `mission.campaign_id`, `mission.status`, `mission.category`).

Also define a `SCHEMA_VERSION = "1.0"` constant.

**Verify:** `python -c "from unfamiliar.db.schema import SCHEMA_SQL, SCHEMA_VERSION"`; `ruff check src/unfamiliar/db/schema.py`; `mypy src/unfamiliar/db/schema.py`

**Done when:** Schema string is syntactically valid SQL, covers all MVP entities and relationships, includes forward-compatible v1.1 columns.

---

### Task 3: Database connection and initialization

**Files:** `src/unfamiliar/db/connection.py`

**Action:** Implement database lifecycle management:

- `open_database(db_path: Path) -> sqlite3.Connection` — opens (or creates) the SQLite database. Enables WAL mode, foreign keys, and returns a connection with `row_factory = sqlite3.Row`.
- `init_database(conn: sqlite3.Connection) -> None` — runs `SCHEMA_SQL` to create tables if they don't exist. Inserts `schema_version` into `meta` table. Sets file permissions to 600 on the database file.
- `get_schema_version(conn: sqlite3.Connection) -> str | None` — reads schema version from `meta` table. Returns None if table doesn't exist.
- `DatabaseError` exception class for database-layer errors.

**Verify:** `ruff check src/unfamiliar/db/connection.py`; `mypy src/unfamiliar/db/connection.py`

**Done when:** Database can be created, initialized with schema, and reopened. Schema version is queryable.

---

### Task 4: Campaign and Briefing repository

**Files:** `src/unfamiliar/db/repositories/campaign.py`

**Action:** Implement `CampaignRepository` class that takes a `sqlite3.Connection` in its constructor. Methods:

- `create(campaign: Campaign) -> Campaign` — INSERT, return with id populated
- `get(campaign_id: int) -> Campaign | None` — SELECT by id
- `get_by_repo_path(repo_path: str) -> Campaign | None` — SELECT by repo_path
- `update_status(campaign_id: int, status: CampaignStatus) -> None` — UPDATE status field
- `update(campaign: Campaign) -> None` — UPDATE all mutable fields
- `create_briefing(briefing: Briefing) -> Briefing` — INSERT briefing
- `get_briefing(campaign_id: int) -> Briefing | None` — SELECT briefing by campaign_id
- `update_briefing(briefing: Briefing) -> None` — UPDATE briefing

All methods map between Pydantic models and database rows using `model_validate()` and `model_dump()`.

**Verify:** `ruff check`; `mypy`; manual: create a temp db, insert a campaign, read it back.

**Done when:** Campaign and Briefing CRUD operations work through typed Python API.

---

### Task 5: Module repository

**Files:** `src/unfamiliar/db/repositories/module.py`

**Action:** Implement `ModuleRepository` class. Methods:

- `create(module: Module) -> Module` — INSERT, return with id
- `create_many(modules: list[Module]) -> list[Module]` — bulk INSERT for analysis results
- `get(module_id: int) -> Module | None`
- `get_by_campaign(campaign_id: int) -> list[Module]`
- `get_by_path(campaign_id: int, path: str) -> Module | None`
- `update(module: Module) -> None`

Handle `category_relevance` JSON serialization (list of InvestigationCategory values stored as JSON text in SQLite, deserialized back to list on read).

**Verify:** `ruff check`; `mypy`

**Done when:** Module CRUD works, JSON category_relevance round-trips correctly.

---

### Task 6: Mission repository

**Files:** `src/unfamiliar/db/repositories/mission.py`

**Action:** Implement `MissionRepository` class. This is the largest repository — handles missions, file references, prerequisites, and module-mission links. Methods:

**Missions:**
- `create(mission: Mission) -> Mission`
- `create_many(missions: list[Mission]) -> list[Mission]`
- `get(mission_id: int) -> Mission | None`
- `get_by_campaign(campaign_id: int) -> list[Mission]`
- `get_by_status(campaign_id: int, status: MissionStatus) -> list[Mission]`
- `get_by_category(campaign_id: int, category: InvestigationCategory) -> list[Mission]`
- `update_status(mission_id: int, status: MissionStatus, completion_method: CompletionMethod | None = None) -> None`

**File references:**
- `add_file_refs(mission_id: int, refs: list[MissionFileRef]) -> None`
- `get_file_refs(mission_id: int) -> list[MissionFileRef]`

**Prerequisites:**
- `add_prerequisites(dependent_id: int, prerequisite_ids: list[int]) -> None`
- `get_prerequisites(mission_id: int) -> list[int]` — returns prerequisite mission IDs
- `get_dependents(mission_id: int) -> list[int]` — returns dependent mission IDs

**Module-mission links:**
- `link_modules(mission_id: int, links: list[ModuleMissionLink]) -> None`
- `get_linked_modules(mission_id: int) -> list[ModuleMissionLink]`
- `get_missions_for_module(module_id: int) -> list[int]` — returns mission IDs

**Verify:** `ruff check`; `mypy`

**Done when:** Full mission CRUD including file refs, prerequisites, and module links.

---

### Task 7: Chain repository

**Files:** `src/unfamiliar/db/repositories/chain.py`

**Action:** Implement `ChainRepository` class. Methods:

- `create(chain: MissionChain) -> MissionChain`
- `create_many(chains: list[MissionChain]) -> list[MissionChain]`
- `get(chain_id: int) -> MissionChain | None`
- `get_by_campaign(campaign_id: int) -> list[MissionChain]`
- `add_missions(chain_id: int, links: list[ChainMissionLink]) -> None`
- `get_chain_missions(chain_id: int) -> list[ChainMissionLink]` — returns links ordered by position
- `get_chains_for_mission(mission_id: int) -> list[int]` — returns chain IDs

**Verify:** `ruff check`; `mypy`

**Done when:** Chain CRUD works including ordered mission links.

---

### Task 8: File snapshot repository

**Files:** `src/unfamiliar/db/repositories/file_snapshot.py`

**Action:** Implement `FileSnapshotRepository` class. Methods:

- `create_many(snapshots: list[FileSnapshot]) -> None` — bulk INSERT for analysis results
- `get_by_campaign(campaign_id: int) -> list[FileSnapshot]`
- `get_by_path(campaign_id: int, file_path: str) -> FileSnapshot | None`
- `upsert(snapshot: FileSnapshot) -> None` — INSERT OR REPLACE for re-analysis

**Verify:** `ruff check`; `mypy`

**Done when:** File snapshot persistence works with upsert for re-analysis scenarios.

---

### Task 9: Repository package and .repo_path marker integration

**Files:** `src/unfamiliar/db/repositories/__init__.py`, `src/unfamiliar/db/__init__.py`

**Action:**
1. Create `repositories/__init__.py` that re-exports all repository classes.
2. Update `db/__init__.py` to expose a convenience function:
   - `open_campaign_db(campaign_dir: Path) -> tuple[sqlite3.Connection, CampaignRepository, ModuleRepository, MissionRepository, ChainRepository, FileSnapshotRepository]` — opens/initializes the database and returns the connection with all repositories. This is the primary entry point for the db package.
3. Add `.repo_path` marker file creation: when a new campaign database is initialized, write the `repo_path` to `<campaign_dir>/.repo_path`. This integrates with the `_campaign_matches_repo()` function in `config.py` that already reads this file.

**Verify:** `ruff check src/unfamiliar/db/`; `mypy src/unfamiliar/db/`

**Done when:** The db package has a single entry point that sets up everything. Marker file is created alongside the database.

---

### Task 10: Full verification

**Files:** All files created in this phase.

**Action:**
1. `ruff check src/unfamiliar/` — full lint pass
2. `mypy src/unfamiliar/` — full type check
3. `python -c "from unfamiliar.db import open_campaign_db"` — verify import chain
4. `python -c "from unfamiliar.models import Campaign, Mission, Module, Briefing, MissionChain, FileSnapshot, MissionFileRef"` — verify all models importable
5. Verify no circular imports between models, db, and config modules

**Verify:** All commands above exit 0.

**Done when:** All lint, type checks, and imports pass cleanly. No regressions in existing Phase 1 code.

---

## Interface Contracts

These contracts define the public API for the data layer. Phase 5 tests will be written against these contracts.

### Models (`src/unfamiliar/models/`)

#### `Campaign`
```python
class Campaign(BaseModel):
    id: int | None = None
    repo_path: str
    schema_version: str = "1.0"
    mode: CampaignMode = CampaignMode.ONBOARDING
    character: CharacterArchetype = CharacterArchetype.DETECTIVE
    status: CampaignStatus = CampaignStatus.INITIALIZING
    theme: Theme = Theme.FILM_NOIR
    created_at: datetime | None = None
    updated_at: datetime | None = None
```
- **Purpose:** Represents a single investigation of a codebase.
- **Invariants:** `repo_path` is always an absolute path. `status` only progresses forward (INITIALIZING → ACTIVE → CLOSED). One campaign per repo_path.
- **Valid inputs:** `repo_path` must be a non-empty string. All enum fields must match their StrEnum values.
- **Error conditions:** Pydantic `ValidationError` on invalid enum values or missing required fields.
- **Edge cases:** Campaign with no missions yet (INITIALIZING state). Campaign with all default v1.1 fields.

#### `Briefing`
```python
class Briefing(BaseModel):
    id: int | None = None
    campaign_id: int
    summary: str
    tech_stack: list[str]
    security_posture: str
    created_at: datetime | None = None
    updated_at: datetime | None = None
```
- **Purpose:** Structured analysis results for a campaign. One-to-one with Campaign.
- **Invariants:** Exactly one briefing per campaign_id. `tech_stack` is a list of strings stored as JSON.
- **Valid inputs:** `campaign_id` must reference an existing campaign. `summary` and `security_posture` are non-empty strings.
- **Error conditions:** ValidationError on missing campaign_id.
- **Edge cases:** Empty tech_stack list. Very long summary text.

#### `Module`
```python
class Module(BaseModel):
    id: int | None = None
    campaign_id: int
    name: str
    path: str
    description: str
    file_count: int = 0
    category_relevance: list[InvestigationCategory] = []
    security_relevant: bool = False
    created_at: datetime | None = None
    updated_at: datetime | None = None
```
- **Purpose:** A logical code unit identified during analysis.
- **Invariants:** `category_relevance` contains valid InvestigationCategory values. `file_count >= 0`.
- **Valid inputs:** `name` and `path` are non-empty. `category_relevance` items are valid enum values.
- **Error conditions:** ValidationError on negative file_count (if constrained) or invalid category values.
- **Edge cases:** Module with empty category_relevance. Module with all 7 categories relevant. `category_relevance` JSON round-trip through SQLite.

#### `Mission`
```python
class Mission(BaseModel):
    id: int | None = None
    campaign_id: int
    title: str
    title_plain: str
    description: str
    category: InvestigationCategory
    difficulty: MissionDifficulty
    time_estimate_minutes: int
    completion_condition: str
    security_tag: bool = False
    standard_tag: str | None = None
    status: MissionStatus = MissionStatus.LOCKED
    completion_method: CompletionMethod | None = None
    narrative_intro: str = ""
    narrative_outro: str = ""
    created_at: datetime | None = None
    updated_at: datetime | None = None
```
- **Purpose:** An atomic investigation task within a campaign.
- **Invariants:** `time_estimate_minutes` matches the difficulty tier (QUICK=15, MEDIUM=30, DEEP_DIVE=45). `completion_method` is None until status is COMPLETED. Status transitions: LOCKED → AVAILABLE → IN_PROGRESS → COMPLETED.
- **Valid inputs:** All string fields non-empty (except narrative fields and standard_tag which are optional). Enum fields match their StrEnum values.
- **Error conditions:** ValidationError on invalid enum values.
- **Edge cases:** Mission with no prerequisites (root mission). Mission with standard_tag set. Mission with security_tag true.

#### `MissionFileRef`
```python
class MissionFileRef(BaseModel):
    id: int | None = None
    mission_id: int
    file_path: str
    line_start: int | None = None
    line_end: int | None = None
    description: str = ""
    file_hash: str = ""
```
- **Purpose:** Links a mission to specific files/lines the user should investigate.
- **Invariants:** If both `line_start` and `line_end` are set, `line_start <= line_end`. `file_path` is relative to repo root.
- **Valid inputs:** `file_path` is non-empty. Line numbers are positive integers or None.
- **Error conditions:** ValidationError on missing mission_id or file_path.
- **Edge cases:** Whole-file reference (both line fields None). Single-line reference (start == end). File reference with empty hash (pre-analysis).

#### `MissionChain`
```python
class MissionChain(BaseModel):
    id: int | None = None
    campaign_id: int
    name: str
    name_plain: str
    description: str
    category: InvestigationCategory
    sort_order: int = 0
    created_at: datetime | None = None
    updated_at: datetime | None = None
```
- **Purpose:** A suggested learning path through related missions.
- **Invariants:** `sort_order` determines display ordering within a campaign.
- **Valid inputs:** `name` and `name_plain` are non-empty. `category` is a valid InvestigationCategory.
- **Error conditions:** ValidationError on invalid category.
- **Edge cases:** Chain with a single mission. Chain spanning multiple categories (primary category recorded).

#### `FileSnapshot`
```python
class FileSnapshot(BaseModel):
    id: int | None = None
    campaign_id: int
    file_path: str
    file_hash: str
    last_analyzed: datetime | None = None
```
- **Purpose:** Tracks file state at analysis time for v1.1 staleness detection.
- **Invariants:** `file_path` + `campaign_id` is unique (one snapshot per file per campaign).
- **Valid inputs:** `file_path` and `file_hash` are non-empty strings.
- **Error conditions:** ValidationError on missing required fields.
- **Edge cases:** Upsert when file is re-analyzed (hash changes).

#### Junction/Link Models
```python
class ModuleMissionLink(BaseModel):
    module_id: int
    mission_id: int
    is_primary: bool = False

class ChainMissionLink(BaseModel):
    chain_id: int
    mission_id: int
    position: int

class MissionPrerequisite(BaseModel):
    prerequisite_id: int
    dependent_id: int
```
- **Purpose:** Represent many-to-many relationships.
- **Invariants:** `ModuleMissionLink` — exactly one link per mission has `is_primary=True`. `ChainMissionLink` — `position` is unique within a chain. `MissionPrerequisite` — no self-references (prerequisite_id != dependent_id).
- **Edge cases:** Mission linked to zero modules (shouldn't happen but handle gracefully). Chain with missions at non-contiguous positions.

---

### Database Connection (`src/unfamiliar/db/connection.py`)

#### `open_database(db_path: Path) -> sqlite3.Connection`
- **Purpose:** Open or create a SQLite database with correct pragmas.
- **Invariants:** Returns a connection with WAL mode, foreign keys ON, and Row factory.
- **Error conditions:** `DatabaseError` if the file path is not writable.
- **Edge cases:** Opening an existing database (no schema init). Path with missing parent directories.

#### `init_database(conn: sqlite3.Connection) -> None`
- **Purpose:** Create all tables if they don't exist and set schema version.
- **Invariants:** Idempotent — safe to call on an already-initialized database.
- **Error conditions:** `DatabaseError` if schema execution fails.
- **Edge cases:** Calling on a database with a different schema version (should not overwrite).

#### `get_schema_version(conn: sqlite3.Connection) -> str | None`
- **Purpose:** Read the current schema version from the meta table.
- **Invariants:** Returns None for uninitialized databases, version string otherwise.
- **Error conditions:** None — returns None on any read failure.
- **Edge cases:** Database file exists but has no meta table.

---

### Repository Layer (`src/unfamiliar/db/repositories/`)

#### `CampaignRepository`

| Method | Signature | Returns | Error conditions |
|--------|-----------|---------|-----------------|
| `create` | `(campaign: Campaign) -> Campaign` | Campaign with id set | `DatabaseError` on duplicate repo_path |
| `get` | `(campaign_id: int) -> Campaign \| None` | Campaign or None | Never raises |
| `get_by_repo_path` | `(repo_path: str) -> Campaign \| None` | Campaign or None | Never raises |
| `update_status` | `(campaign_id: int, status: CampaignStatus) -> None` | None | `DatabaseError` if campaign not found |
| `update` | `(campaign: Campaign) -> None` | None | `DatabaseError` if campaign.id is None |
| `create_briefing` | `(briefing: Briefing) -> Briefing` | Briefing with id set | `DatabaseError` on duplicate campaign_id |
| `get_briefing` | `(campaign_id: int) -> Briefing \| None` | Briefing or None | Never raises |
| `update_briefing` | `(briefing: Briefing) -> None` | None | `DatabaseError` if briefing.id is None |

#### `ModuleRepository`

| Method | Signature | Returns | Error conditions |
|--------|-----------|---------|-----------------|
| `create` | `(module: Module) -> Module` | Module with id set | `DatabaseError` on insert failure |
| `create_many` | `(modules: list[Module]) -> list[Module]` | Modules with ids | `DatabaseError` on insert failure |
| `get` | `(module_id: int) -> Module \| None` | Module or None | Never raises |
| `get_by_campaign` | `(campaign_id: int) -> list[Module]` | List (may be empty) | Never raises |
| `get_by_path` | `(campaign_id: int, path: str) -> Module \| None` | Module or None | Never raises |
| `update` | `(module: Module) -> None` | None | `DatabaseError` if module.id is None |

- **JSON round-trip:** `category_relevance` stored as `json.dumps([str(c) for c in cats])`, deserialized as `[InvestigationCategory(v) for v in json.loads(text)]`.

#### `MissionRepository`

| Method | Signature | Returns | Error conditions |
|--------|-----------|---------|-----------------|
| `create` | `(mission: Mission) -> Mission` | Mission with id set | `DatabaseError` on insert failure |
| `create_many` | `(missions: list[Mission]) -> list[Mission]` | Missions with ids | `DatabaseError` on insert failure |
| `get` | `(mission_id: int) -> Mission \| None` | Mission or None | Never raises |
| `get_by_campaign` | `(campaign_id: int) -> list[Mission]` | List (may be empty) | Never raises |
| `get_by_status` | `(campaign_id: int, status: MissionStatus) -> list[Mission]` | Filtered list | Never raises |
| `get_by_category` | `(campaign_id: int, category: InvestigationCategory) -> list[Mission]` | Filtered list | Never raises |
| `update_status` | `(mission_id: int, status: MissionStatus, completion_method: CompletionMethod \| None = None) -> None` | None | `DatabaseError` if mission not found |
| `add_file_refs` | `(mission_id: int, refs: list[MissionFileRef]) -> None` | None | `DatabaseError` on insert failure |
| `get_file_refs` | `(mission_id: int) -> list[MissionFileRef]` | List (may be empty) | Never raises |
| `add_prerequisites` | `(dependent_id: int, prerequisite_ids: list[int]) -> None` | None | `DatabaseError` on invalid mission IDs |
| `get_prerequisites` | `(mission_id: int) -> list[int]` | List of mission IDs | Never raises |
| `get_dependents` | `(mission_id: int) -> list[int]` | List of mission IDs | Never raises |
| `link_modules` | `(mission_id: int, links: list[ModuleMissionLink]) -> None` | None | `DatabaseError` on invalid IDs |
| `get_linked_modules` | `(mission_id: int) -> list[ModuleMissionLink]` | List (may be empty) | Never raises |
| `get_missions_for_module` | `(module_id: int) -> list[int]` | List of mission IDs | Never raises |

#### `ChainRepository`

| Method | Signature | Returns | Error conditions |
|--------|-----------|---------|-----------------|
| `create` | `(chain: MissionChain) -> MissionChain` | Chain with id set | `DatabaseError` on insert failure |
| `create_many` | `(chains: list[MissionChain]) -> list[MissionChain]` | Chains with ids | `DatabaseError` on insert failure |
| `get` | `(chain_id: int) -> MissionChain \| None` | Chain or None | Never raises |
| `get_by_campaign` | `(campaign_id: int) -> list[MissionChain]` | List (may be empty) | Never raises |
| `add_missions` | `(chain_id: int, links: list[ChainMissionLink]) -> None` | None | `DatabaseError` on invalid IDs |
| `get_chain_missions` | `(chain_id: int) -> list[ChainMissionLink]` | Ordered by position | Never raises |
| `get_chains_for_mission` | `(mission_id: int) -> list[int]` | List of chain IDs | Never raises |

#### `FileSnapshotRepository`

| Method | Signature | Returns | Error conditions |
|--------|-----------|---------|-----------------|
| `create_many` | `(snapshots: list[FileSnapshot]) -> None` | None | `DatabaseError` on insert failure |
| `get_by_campaign` | `(campaign_id: int) -> list[FileSnapshot]` | List (may be empty) | Never raises |
| `get_by_path` | `(campaign_id: int, file_path: str) -> FileSnapshot \| None` | Snapshot or None | Never raises |
| `upsert` | `(snapshot: FileSnapshot) -> None` | None | `DatabaseError` on insert failure |

---

### Entry Point (`src/unfamiliar/db/__init__.py`)

#### `open_campaign_db(campaign_dir: Path) -> tuple[Connection, CampaignRepository, ModuleRepository, MissionRepository, ChainRepository, FileSnapshotRepository]`
- **Purpose:** Single entry point to open/initialize a campaign database and get all repositories.
- **Invariants:** Creates `campaign.db` inside `campaign_dir`. Creates `.repo_path` marker if it doesn't exist (caller must provide repo_path separately via campaign creation).
- **Error conditions:** `DatabaseError` if directory doesn't exist or isn't writable.
- **Edge cases:** Called on an already-initialized campaign directory (idempotent).

---

## Property-Based Testing Candidates (for Phase 5)

These pure functions with clear invariants are good candidates for Hypothesis:

1. **JSON round-trip for category_relevance** — any list of InvestigationCategory values survives serialize → deserialize
2. **Model validation** — any valid field combination produces a valid model; any invalid enum produces ValidationError
3. **Schema idempotency** — running init_database twice on the same connection produces no errors
4. **Campaign status transitions** — only forward transitions succeed
5. **Prerequisite graph** — prerequisite_id != dependent_id always holds

---

## Security Checklist

All security concerns for the data layer have been addressed:

- [x] **SQL Injection Prevention:** All queries use parameterized statements via `?` placeholders. No string interpolation in SQL.
- [x] **Database File Permissions:** Database files are set to mode 600 (owner-only) on creation via `init_database()`.
- [x] **Marker File Permissions:** The `.repo_path` marker file is set to mode 600 when created.
- [x] **No User-Supplied SQL:** The repository layer is the only interface to the database. Raw SQL is never accepted from callers.
- [x] **Error Message Safety:** `DatabaseError` messages are generic and do not leak internal details (table names, column names, or query fragments).
- [x] **Foreign Key Constraints:** Enabled via `PRAGMA foreign_keys=ON` to ensure referential integrity.
- [x] **Input Validation:** Pydantic models validate all input before it reaches the database layer. Enum fields reject invalid values.

---

## Issues Discovered During Verification Stage

No issues discovered. All verification steps passed:

- ruff check: All checks passed
- mypy: Success with no issues in 33 source files
- Import verification: All models and db package import correctly
- Integration test: All repositories work end-to-end
- JSON round-trip: category_relevance serializes/deserializes correctly
