# Data Model - v0.2.0 **Version**: 0.2.0 **Date**: 2025-12-22 **Status**: Phase 2 Design ## Introduction This document defines the complete database schema for Sneaky Klaus. The schema is designed for SQLite with SQLAlchemy ORM, optimized for read-heavy workloads with occasional writes, and structured to support all user stories in the product backlog. **Phase 2 Additions**: This version fully implements the Participant and MagicToken tables that were designed in v0.1.0 but not yet utilized. These tables enable participant registration, magic link authentication, and participant sessions. **Note**: Session storage is managed by Flask-Session, which creates and manages its own session table. The custom Session table previously defined has been removed in favor of Flask-Session's implementation. ## Entity Relationship Diagram ```mermaid erDiagram Admin ||--o{ Exchange : creates Exchange ||--o{ Participant : contains Exchange ||--o{ ExclusionRule : defines Exchange ||--o{ NotificationPreference : configures Participant ||--o{ Match : "gives to" Participant ||--o{ Match : "receives from" Participant ||--o{ MagicToken : authenticates_with Participant }o--o{ ExclusionRule : excluded_from Exchange { int id PK string slug UK string name text description string budget int max_participants datetime registration_close_date datetime exchange_date string timezone string state datetime created_at datetime updated_at datetime completed_at } Admin { int id PK string email UK string password_hash datetime created_at datetime updated_at } Participant { int id PK int exchange_id FK string name string email text gift_ideas boolean reminder_enabled datetime created_at datetime updated_at datetime withdrawn_at } Match { int id PK int exchange_id FK int giver_id FK int receiver_id FK datetime created_at } ExclusionRule { int id PK int exchange_id FK int participant_a_id FK int participant_b_id FK datetime created_at } MagicToken { int id PK string token_hash UK string token_type string email int participant_id FK int exchange_id FK datetime created_at datetime expires_at datetime used_at } PasswordResetToken { int id PK string token_hash UK string email datetime created_at datetime expires_at datetime used_at } RateLimit { int id PK string key UK int attempts datetime window_start datetime expires_at } NotificationPreference { int id PK int exchange_id FK boolean new_registration boolean participant_withdrawal boolean matching_complete datetime created_at datetime updated_at } ``` ## Entity Definitions ### Admin The administrator account for the entire installation. Only one admin exists per deployment. **Table**: `admin` | Column | Type | Constraints | Description | |--------|------|-------------|-------------| | `id` | INTEGER | PRIMARY KEY | Auto-increment ID | | `email` | VARCHAR(255) | UNIQUE, NOT NULL | Admin email address | | `password_hash` | VARCHAR(255) | NOT NULL | bcrypt password hash | | `created_at` | TIMESTAMP | NOT NULL, DEFAULT NOW | Account creation timestamp | | `updated_at` | TIMESTAMP | NOT NULL, DEFAULT NOW | Last update timestamp | **Indexes**: - `idx_admin_email` on `email` (unique) **Constraints**: - Email format validation at application level - Only one admin record should exist (enforced at application level) **Notes**: - Password hash uses bcrypt with cost factor 12 - `updated_at` automatically updated on any modification --- ### Exchange Represents a single Secret Santa exchange event. **Table**: `exchange` | Column | Type | Constraints | Description | |--------|------|-------------|-------------| | `id` | INTEGER | PRIMARY KEY | Auto-increment ID | | `slug` | VARCHAR(12) | UNIQUE, NOT NULL | URL-safe identifier for exchange | | `name` | VARCHAR(255) | NOT NULL | Exchange name/title | | `description` | TEXT | NULLABLE | Optional description | | `budget` | VARCHAR(100) | NOT NULL | Gift budget (e.g., "$20-30") | | `max_participants` | INTEGER | NOT NULL, CHECK >= 3 | Maximum participant limit | | `registration_close_date` | TIMESTAMP | NOT NULL | When registration ends | | `exchange_date` | TIMESTAMP | NOT NULL | When gifts are exchanged | | `timezone` | VARCHAR(50) | NOT NULL | Timezone for dates (e.g., "America/New_York") | | `state` | VARCHAR(20) | NOT NULL | Current state (see states below) | | `created_at` | TIMESTAMP | NOT NULL, DEFAULT NOW | Exchange creation timestamp | | `updated_at` | TIMESTAMP | NOT NULL, DEFAULT NOW | Last update timestamp | | `completed_at` | TIMESTAMP | NULLABLE | When exchange was marked complete | **Indexes**: - `idx_exchange_slug` on `slug` (unique) - `idx_exchange_state` on `state` - `idx_exchange_exchange_date` on `exchange_date` - `idx_exchange_completed_at` on `completed_at` **States** (enum enforced at application level): - `draft`: Exchange created but not accepting registrations - `registration_open`: Participants can register - `registration_closed`: Registration ended, ready for matching - `matched`: Participants have been assigned recipients - `completed`: Exchange date has passed **State Transitions**: - `draft` → `registration_open` - `registration_open` → `registration_closed` - `registration_closed` → `registration_open` (reopen) - `registration_closed` → `matched` (after matching) - `matched` → `registration_open` (reopen, clears matches) - `matched` → `completed` **Constraints**: - `registration_close_date` must be before `exchange_date` (validated at application level) - `max_participants` minimum value: 3 - Timezone must be valid IANA timezone (validated at application level) - `slug` must be unique across all exchanges **Slug Generation**: - Generated on exchange creation using `secrets.choice()` from Python's secrets module - 12 URL-safe alphanumeric characters (a-z, A-Z, 0-9) - Immutable once generated (never changes) - Used in public URLs for exchange registration and participant access **Cascade Behavior**: - Deleting exchange cascades to: participants, matches, exclusion rules, notification preferences --- ### Participant A person registered in a specific exchange. **Table**: `participant` | Column | Type | Constraints | Description | |--------|------|-------------|-------------| | `id` | INTEGER | PRIMARY KEY | Auto-increment ID | | `exchange_id` | INTEGER | FOREIGN KEY → exchange.id, NOT NULL | Associated exchange | | `name` | VARCHAR(255) | NOT NULL | Display name | | `email` | VARCHAR(255) | NOT NULL | Email address | | `gift_ideas` | TEXT | NULLABLE | Wishlist/gift preferences | | `reminder_enabled` | BOOLEAN | NOT NULL, DEFAULT TRUE | Opt-in for reminder emails | | `created_at` | TIMESTAMP | NOT NULL, DEFAULT NOW | Registration timestamp | | `updated_at` | TIMESTAMP | NOT NULL, DEFAULT NOW | Last update timestamp | | `withdrawn_at` | TIMESTAMP | NULLABLE | Withdrawal timestamp (soft delete) | **Indexes**: - `idx_participant_exchange_id` on `exchange_id` - `idx_participant_email` on `email` - `idx_participant_exchange_email` on `(exchange_id, email)` (composite unique) **Constraints**: - Email must be unique within an exchange (composite unique index) - Email format validation at application level - Cannot modify after matching except gift_ideas and reminder_enabled (enforced at application level) **Cascade Behavior**: - Deleting exchange cascades to delete participants - Deleting participant cascades to: matches (as giver or receiver), exclusion rules, magic tokens **Soft Delete**: - Withdrawal sets `withdrawn_at` instead of hard delete - Withdrawn participants excluded from matching and participant lists - Withdrawn participants cannot be re-activated (must re-register) --- ### Match Represents a giver-receiver assignment in an exchange. **Table**: `match` | Column | Type | Constraints | Description | |--------|------|-------------|-------------| | `id` | INTEGER | PRIMARY KEY | Auto-increment ID | | `exchange_id` | INTEGER | FOREIGN KEY → exchange.id, NOT NULL | Associated exchange | | `giver_id` | INTEGER | FOREIGN KEY → participant.id, NOT NULL | Participant giving gift | | `receiver_id` | INTEGER | FOREIGN KEY → participant.id, NOT NULL | Participant receiving gift | | `created_at` | TIMESTAMP | NOT NULL, DEFAULT NOW | Match creation timestamp | **Indexes**: - `idx_match_exchange_id` on `exchange_id` - `idx_match_giver_id` on `giver_id` - `idx_match_receiver_id` on `receiver_id` - `idx_match_exchange_giver` on `(exchange_id, giver_id)` (composite unique) **Constraints**: - Each participant can be a giver exactly once per exchange (composite unique) - Each participant can be a receiver exactly once per exchange (enforced at application level) - `giver_id` cannot equal `receiver_id` (no self-matching, enforced at application level) - Both giver and receiver must belong to same exchange (enforced at application level) **Cascade Behavior**: - Deleting exchange cascades to delete matches - Deleting participant cascades to delete matches (triggers re-match requirement) **Validation**: - All participants in exchange must have exactly one match as giver and one as receiver - No exclusion rules violated (enforced during matching) --- ### ExclusionRule Defines pairs of participants who should not be matched together. **Table**: `exclusion_rule` | Column | Type | Constraints | Description | |--------|------|-------------|-------------| | `id` | INTEGER | PRIMARY KEY | Auto-increment ID | | `exchange_id` | INTEGER | FOREIGN KEY → exchange.id, NOT NULL | Associated exchange | | `participant_a_id` | INTEGER | FOREIGN KEY → participant.id, NOT NULL | First participant | | `participant_b_id` | INTEGER | FOREIGN KEY → participant.id, NOT NULL | Second participant | | `created_at` | TIMESTAMP | NOT NULL, DEFAULT NOW | Rule creation timestamp | **Indexes**: - `idx_exclusion_exchange_id` on `exchange_id` - `idx_exclusion_participants` on `(exchange_id, participant_a_id, participant_b_id)` (composite unique) **Constraints**: - `participant_a_id` and `participant_b_id` must be different (enforced at application level) - Both participants must belong to same exchange (enforced at application level) - Exclusion is bidirectional: A→B exclusion also means B→A (handled at application level) - Prevent duplicate rules with swapped participant IDs (enforced by ordering IDs: `participant_a_id < participant_b_id`) **Cascade Behavior**: - Deleting exchange cascades to delete exclusion rules - Deleting participant cascades to delete related exclusion rules **Application Logic**: - When adding exclusion, always store with lower ID as participant_a, higher ID as participant_b - Matching algorithm treats exclusion as bidirectional --- ### MagicToken Time-limited tokens for participant passwordless authentication and admin password reset. **Table**: `magic_token` | Column | Type | Constraints | Description | |--------|------|-------------|-------------| | `id` | INTEGER | PRIMARY KEY | Auto-increment ID | | `token_hash` | VARCHAR(255) | UNIQUE, NOT NULL | SHA-256 hash of token | | `token_type` | VARCHAR(20) | NOT NULL | 'magic_link' or 'password_reset' | | `email` | VARCHAR(255) | NOT NULL | Email address token sent to | | `participant_id` | INTEGER | FOREIGN KEY → participant.id, NULLABLE | For magic links only | | `exchange_id` | INTEGER | FOREIGN KEY → exchange.id, NULLABLE | For magic links only | | `created_at` | TIMESTAMP | NOT NULL, DEFAULT NOW | Token creation timestamp | | `expires_at` | TIMESTAMP | NOT NULL | Token expiration (1 hour from creation) | | `used_at` | TIMESTAMP | NULLABLE | When token was consumed | **Indexes**: - `idx_magic_token_hash` on `token_hash` (unique) - `idx_magic_token_type_email` on `(token_type, email)` - `idx_magic_token_expires_at` on `expires_at` **Constraints**: - `token_type` must be 'magic_link' or 'password_reset' (enforced at application level) - For magic_link: `participant_id` and `exchange_id` must be NOT NULL - For password_reset: `participant_id` and `exchange_id` must be NULL - Tokens expire 1 hour after creation - Tokens are single-use (validated via `used_at`) **Token Generation**: - Token: 32-byte random value from `secrets` module, base64url encoded - Hash: SHA-256 hash of token (stored in database) - Original token sent in email, never stored **Validation**: - Token valid if: hash matches, not expired, not used - On successful validation: set `used_at` timestamp, create session **Cleanup**: - Expired or used tokens purged hourly via background job **Cascade Behavior**: - Deleting participant cascades to delete magic tokens - Deleting exchange cascades to delete related magic tokens --- ### RateLimit Tracks authentication attempt rate limits per email/key. **Table**: `rate_limit` | Column | Type | Constraints | Description | |--------|------|-------------|-------------| | `id` | INTEGER | PRIMARY KEY | Auto-increment ID | | `key` | VARCHAR(255) | UNIQUE, NOT NULL | Rate limit key (e.g., "login:email@example.com") | | `attempts` | INTEGER | NOT NULL, DEFAULT 0 | Number of attempts in current window | | `window_start` | TIMESTAMP | NOT NULL, DEFAULT NOW | Start of current rate limit window | | `expires_at` | TIMESTAMP | NOT NULL | When rate limit resets | **Indexes**: - `idx_rate_limit_key` on `key` (unique) - `idx_rate_limit_expires_at` on `expires_at` **Rate Limit Policies**: - Admin login: 5 attempts per 15 minutes per email - Participant magic link: 3 requests per hour per email - Password reset: 3 requests per hour per email **Key Format**: - Admin login: `login:admin:{email}` - Magic link: `magic_link:{email}` - Password reset: `password_reset:{email}` **Workflow**: 1. Check if key exists and within window 2. If attempts exceeded: reject request 3. If within limits: increment attempts 4. If window expired: reset attempts and window **Cleanup**: - Expired rate limit entries purged daily via background job --- ### NotificationPreference Admin's email notification preferences per exchange or globally. **Table**: `notification_preference` | Column | Type | Constraints | Description | |--------|------|-------------|-------------| | `id` | INTEGER | PRIMARY KEY | Auto-increment ID | | `exchange_id` | INTEGER | FOREIGN KEY → exchange.id, NULLABLE | Specific exchange (NULL = global default) | | `new_registration` | BOOLEAN | NOT NULL, DEFAULT TRUE | Notify on new participant registration | | `participant_withdrawal` | BOOLEAN | NOT NULL, DEFAULT TRUE | Notify on participant withdrawal | | `matching_complete` | BOOLEAN | NOT NULL, DEFAULT TRUE | Notify on successful matching | | `created_at` | TIMESTAMP | NOT NULL, DEFAULT NOW | Preference creation timestamp | | `updated_at` | TIMESTAMP | NOT NULL, DEFAULT NOW | Last update timestamp | **Indexes**: - `idx_notification_exchange_id` on `exchange_id` (unique) **Constraints**: - Only one notification preference per exchange (unique index) - Only one global preference (exchange_id = NULL) **Application Logic**: - If exchange-specific preference exists, use it - Otherwise, fall back to global preference - If no preferences exist, default all to TRUE **Cascade Behavior**: - Deleting exchange cascades to delete notification preferences --- ## Data Types & Conventions ### Timestamps - **Type**: `TIMESTAMP` (SQLite stores as ISO 8601 string) - **Timezone**: All timestamps stored in UTC - **Application Layer**: Convert to exchange timezone for display - **Automatic Fields**: `created_at`, `updated_at` managed by SQLAlchemy ### Email Addresses - **Type**: `VARCHAR(255)` - **Validation**: RFC 5322 format validation at application level - **Storage**: Lowercase normalized - **Indexing**: Indexed for lookup performance ### Enumerations SQLite doesn't support native enums. Enforced at application level: **ExchangeState**: - `draft` - `registration_open` - `registration_closed` - `matched` - `completed` **UserType** (Session): - `admin` - `participant` **TokenType** (MagicToken): - `magic_link` - `password_reset` ### JSON Fields - **Type**: `TEXT` (SQLite) - **Serialization**: JSON string - **Access**: SQLAlchemy JSON type provides automatic serialization/deserialization - **Usage**: Session data, future extensibility ### Boolean Fields - **Type**: `BOOLEAN` (SQLite stores as INTEGER: 0 or 1) - **Default**: Explicit defaults defined per field - **Access**: SQLAlchemy Boolean type handles conversion --- ## Performance Optimization ### Indexes Summary | Table | Index Name | Columns | Type | Purpose | |-------|------------|---------|------|---------| | admin | idx_admin_email | email | UNIQUE | Login lookup | | exchange | idx_exchange_slug | slug | UNIQUE | Exchange lookup by slug | | exchange | idx_exchange_state | state | INDEX | Filter by state | | exchange | idx_exchange_exchange_date | exchange_date | INDEX | Auto-completion job | | exchange | idx_exchange_completed_at | completed_at | INDEX | Purge job | | participant | idx_participant_exchange_id | exchange_id | INDEX | List participants | | participant | idx_participant_email | email | INDEX | Lookup by email | | participant | idx_participant_exchange_email | exchange_id, email | UNIQUE | Email uniqueness per exchange | | match | idx_match_exchange_id | exchange_id | INDEX | List matches | | match | idx_match_giver_id | giver_id | INDEX | Lookup giver's match | | match | idx_match_receiver_id | receiver_id | INDEX | Lookup receivers | | match | idx_match_exchange_giver | exchange_id, giver_id | UNIQUE | Prevent duplicate givers | | exclusion_rule | idx_exclusion_exchange_id | exchange_id | INDEX | List exclusions | | exclusion_rule | idx_exclusion_participants | exchange_id, participant_a_id, participant_b_id | UNIQUE | Prevent duplicates | | magic_token | idx_magic_token_hash | token_hash | UNIQUE | Token validation | | magic_token | idx_magic_token_type_email | token_type, email | INDEX | Rate limit checks | | magic_token | idx_magic_token_expires_at | expires_at | INDEX | Cleanup job | | rate_limit | idx_rate_limit_key | key | UNIQUE | Rate limit lookup | | rate_limit | idx_rate_limit_expires_at | expires_at | INDEX | Cleanup job | | notification_preference | idx_notification_exchange_id | exchange_id | UNIQUE | Preference lookup | ### Query Optimization Strategies 1. **Eager Loading**: Use SQLAlchemy `joinedload()` for relationships to prevent N+1 queries 2. **Batch Operations**: Use bulk insert/update for matching operations 3. **Filtered Queries**: Always filter by exchange_id first (most selective) 4. **Connection Pooling**: Disabled for SQLite (single file, no benefit) 5. **WAL Mode**: Enabled for better read concurrency ### SQLite Configuration ```python # Connection string DATABASE_URL = 'sqlite:///data/sneaky-klaus.db' # Pragmas (set on connection) PRAGMA journal_mode = WAL; # Write-Ahead Logging PRAGMA foreign_keys = ON; # Enforce foreign keys PRAGMA synchronous = NORMAL; # Balance safety and performance PRAGMA temp_store = MEMORY; # Temporary tables in memory PRAGMA cache_size = -64000; # 64MB cache ``` --- ## Data Validation Rules ### Exchange - `name`: 1-255 characters, required - `slug`: Exactly 12 URL-safe alphanumeric characters, auto-generated, unique - `budget`: 1-100 characters, required (freeform text) - `max_participants`: ≥ 3, required - `registration_close_date` < `exchange_date`: validated at application level - `timezone`: Valid IANA timezone name ### Participant - `name`: 1-255 characters, required - `email`: Valid email format, unique per exchange - `gift_ideas`: 0-10,000 characters (optional) ### Match - Validation at matching time: - All participants have exactly one match (as giver and receiver) - No self-matches - No exclusion rules violated - Single cycle preferred ### ExclusionRule - `participant_a_id` < `participant_b_id`: enforced when creating - Both participants must be in same exchange - Cannot exclude participant from themselves ### MagicToken - Token expiration: 1 hour from creation - Single use only - Token hash must be unique --- ## Audit Trail ### Change Tracking - All tables have `created_at` timestamp - Most tables have `updated_at` timestamp (auto-updated) - Soft deletes used where appropriate (`withdrawn_at` for participants) ### Future Audit Enhancements If detailed audit trail is needed: - Create `audit_log` table - Track: entity type, entity ID, action, user, timestamp, changes - Trigger-based or application-level logging --- ## Migration Strategy ### Initial Schema Creation Use Alembic for database migrations: ```bash # Create initial migration alembic revision --autogenerate -m "Initial schema" # Apply migration alembic upgrade head ``` ### Schema Versioning - Alembic tracks migration version in `alembic_version` table - Each schema change creates new migration file - Migrations can be rolled back if needed ### Future Schema Changes When adding fields or tables: 1. Create Alembic migration 2. Test migration on copy of production database 3. Run migration during deployment 4. Update SQLAlchemy models --- ## Sample Data Relationships ### Example: Complete Exchange Flow 1. **Exchange Created** (state: draft) - 1 exchange record created 2. **Registration Opens** (state: registration_open) - Exchange state updated - Participants register (3-100 participant records) 3. **Registration Closes** (state: registration_closed) - Exchange state updated - Admin adds exclusion rules (0-N exclusion_rule records) 4. **Matching Occurs** (state: matched) - Exchange state updated - Match records created (N matches for N participants) - Magic tokens created for notifications 5. **Exchange Completes** (state: completed) - Exchange state updated - `completed_at` timestamp set - 30-day retention countdown begins 6. **Data Purge** (30 days after completion) - Exchange deleted (cascades to participants, matches, exclusions) --- ## Database Size Estimates ### Assumptions - 50 exchanges per installation - Average 20 participants per exchange - 3 exclusion rules per exchange - 30-day retention = 5 active exchanges + 5 completed ### Storage Calculation | Entity | Records | Avg Size | Total | |--------|---------|----------|-------| | Admin | 1 | 500 B | 500 B | | Exchange | 50 | 1 KB | 50 KB | | Participant | 1,000 | 2 KB | 2 MB | | Match | 1,000 | 200 B | 200 KB | | ExclusionRule | 150 | 200 B | 30 KB | | Flask-Session (managed) | 50 | 500 B | 25 KB | | MagicToken | 200 | 500 B | 100 KB | | RateLimit | 100 | 300 B | 30 KB | | NotificationPreference | 50 | 300 B | 15 KB | **Total Estimated Size**: ~3 MB (excluding indexes and overhead) **With Indexes & Overhead**: ~10 MB typical, ~50 MB maximum SQLite database file size well within acceptable limits for self-hosted deployment. --- ## Backup & Recovery ### Backup Strategy **Database File Location**: `/app/data/sneaky-klaus.db` **Backup Methods**: 1. **Volume Snapshot**: Recommended for Docker deployments ```bash docker run --rm -v sneaky-klaus-data:/data -v $(pwd):/backup \ alpine tar czf /backup/sneaky-klaus-backup-$(date +%Y%m%d).tar.gz /data ``` 2. **SQLite Backup Command**: Online backup without downtime ```bash sqlite3 sneaky-klaus.db ".backup sneaky-klaus-backup.db" ``` 3. **File Copy**: Requires application shutdown ```bash docker stop sneaky-klaus cp /app/data/sneaky-klaus.db /backups/ docker start sneaky-klaus ``` **Backup Frequency**: Daily (automated via cron or external backup tool) **Retention**: 30 days of backups ### Recovery Procedure 1. Stop application container 2. Replace database file with backup 3. Restart application 4. Verify health endpoint 5. Test basic functionality (login, view exchanges) --- ## Constraints Summary ### Foreign Key Relationships | Child Table | Column | Parent Table | Parent Column | On Delete | |-------------|--------|--------------|---------------|-----------| | participant | exchange_id | exchange | id | CASCADE | | match | exchange_id | exchange | id | CASCADE | | match | giver_id | participant | id | CASCADE | | match | receiver_id | participant | id | CASCADE | | exclusion_rule | exchange_id | exchange | id | CASCADE | | exclusion_rule | participant_a_id | participant | id | CASCADE | | exclusion_rule | participant_b_id | participant | id | CASCADE | | magic_token | participant_id | participant | id | CASCADE | | magic_token | exchange_id | exchange | id | CASCADE | | notification_preference | exchange_id | exchange | id | CASCADE | **Flask-Session**: Managed by Flask-Session extension (no foreign keys in application schema) **RateLimit**: No foreign keys (key-based, not entity-based) ### Unique Constraints - `admin.email`: UNIQUE - `exchange.slug`: UNIQUE - `participant.(exchange_id, email)`: UNIQUE (composite) - `match.(exchange_id, giver_id)`: UNIQUE (composite) - `exclusion_rule.(exchange_id, participant_a_id, participant_b_id)`: UNIQUE (composite) - `magic_token.token_hash`: UNIQUE - `rate_limit.key`: UNIQUE - `notification_preference.exchange_id`: UNIQUE (one preference per exchange) ### Check Constraints - `exchange.max_participants >= 3` - Application-level validation for additional constraints (state transitions, date ordering, etc.) --- ## Future Schema Enhancements Potential additions for future versions: 1. **Audit Log Table**: Track all changes for compliance/debugging 2. **Email Queue Table**: Decouple email sending from transactional operations 3. **Participant Groups**: Support for couple/family groupings (exclude from each other automatically) 4. **Multiple Admins**: Add admin roles and permissions 5. **Exchange Templates**: Reusable exchange configurations 6. **Custom Fields**: User-defined participant attributes These enhancements would require schema changes but are out of scope for v0.1.0. --- ## References - [SQLAlchemy Documentation](https://docs.sqlalchemy.org/) - [SQLite Documentation](https://www.sqlite.org/docs.html) - [Alembic Documentation](https://alembic.sqlalchemy.org/) - [ADR-0001: Core Technology Stack](../../decisions/0001-core-technology-stack.md) - [ADR-0002: Authentication Strategy](../../decisions/0002-authentication-strategy.md)