Files
lifeos-dev/project-docs/lifeos-system-design-document.md

2069 lines
77 KiB
Markdown

# Life OS - Comprehensive System Design Document
**Version:** 1.0
**Date:** 2026-03-02
**Purpose:** Complete rebuild reference. Contains everything needed to reconstruct this system from ground zero.
---
# Part 1: System Overview
## 1. Project Identity & Purpose
Life OS is a **single-user personal productivity web application**. It is a unified system for managing every dimension of personal productivity: tasks, projects, notes, contacts, meetings, decisions, time tracking, file management, and more.
**Design Philosophy:**
- **KISS.** Simple wins. Complexity must earn its place.
- **Logical deletes everywhere.** No data permanently destroyed without admin confirmation.
- **Generic over specific.** Shared base code. Config-driven where possible.
- **Consistent patterns.** Every list, form, detail view follows identical conventions.
- **Search is first-class.** Every entity gets PostgreSQL full-text search via tsvector/tsquery.
- **Context travels with navigation.** Drill-down pre-fills context into create forms.
- **Single source of truth.** One system for everything.
**Live URL:** `https://lifeos-dev.invixiom.com`
**Server:** defiant-01, 46.225.166.142, Ubuntu 24.04
**Repository:** `github.com/mdombaugh/lifeos-dev` (main branch)
---
## 2. Architecture Overview
Life OS is a **server-rendered monolith**. There is no SPA, no frontend framework, and no build pipeline.
**Key Architectural Decisions:**
| Decision | Choice | Rationale |
|----------|--------|-----------|
| Rendering | Server-side (Jinja2) | Simplicity; no JS framework to maintain |
| Data mutations | HTML form POST with 303 redirect (PRG) | Standard web pattern; no XHR/fetch for data ops |
| JavaScript role | UI state only (sidebar, search, timer, theme) | Minimal client JS; zero fetch for data changes |
| ORM | None. Raw SQL via `text()` | Full control; no migration tooling needed |
| CRUD abstraction | Generic BaseRepository class | One class handles all 48 tables |
| Deletion | Soft delete everywhere | `is_deleted` + `deleted_at`; admin trash for recovery |
| Search | PostgreSQL tsvector/tsquery | Built-in, no external search service |
| Authentication | None (single user) | Phase 3 roadmap item |
| CSRF | None (single user) | Not needed for single-user system |
**Request Flow:**
```
Browser → Nginx (SSL termination) → Docker container (uvicorn) → FastAPI router
→ BaseRepository / raw SQL → PostgreSQL → Jinja2 template → HTML response
```
**Data Mutation Flow (PRG Pattern):**
```
Browser submits HTML <form> via POST → Router processes Form() params
→ BaseRepository.create/update/soft_delete → 303 redirect → GET page
```
---
## 3. Technology Stack
Every dependency with exact version:
### Python Dependencies (requirements.txt)
| Package | Version | Purpose |
|---------|---------|---------|
| fastapi | 0.115.6 | Web framework (async) |
| uvicorn[standard] | 0.34.0 | ASGI server with hot reload |
| sqlalchemy[asyncio] | 2.0.36 | SQL toolkit (async engine, `text()` only - NO ORM models) |
| asyncpg | 0.30.0 | PostgreSQL async driver |
| jinja2 | 3.1.4 | Template engine |
| python-multipart | 0.0.18 | Form data parsing |
| python-dotenv | 1.0.1 | Environment variable loading |
| pydantic | 2.10.3 | Data validation (FastAPI dependency) |
| pyyaml | 6.0.2 | Configuration parsing |
| aiofiles | 24.1.0 | Async file I/O for uploads |
### Infrastructure
| Component | Version | Purpose |
|-----------|---------|---------|
| Python | 3.12-slim (Docker) | Application runtime |
| PostgreSQL | 16-alpine (Docker) | Database (3 databases: dev, prod, test) |
| Nginx | Host-level (Ubuntu) | Reverse proxy, SSL termination |
| Docker | Host-level | Container runtime |
| Let's Encrypt | Auto-renew | SSL certificates |
### Frontend (Zero Build)
| Component | Source | Purpose |
|-----------|--------|---------|
| Vanilla CSS | `/static/style.css` | All styling, ~1200 lines |
| Vanilla JS | `/static/app.js` | UI interactions only, ~190 lines |
| DM Sans | Google Fonts CDN | Body font |
| JetBrains Mono | Google Fonts CDN | Code/monospace font |
**Hard Rules:**
- No npm. No React. No Tailwind. No build tools.
- No fetch/XHR for data mutations. Forms use standard POST with 303 redirect.
- JavaScript handles ONLY: sidebar collapse, search modal, timer display, theme toggle, mobile nav, filter auto-submit, delete confirmation.
---
# Part 2: Infrastructure
## 4. Server & Network Topology
**Server:** defiant-01 (46.225.166.142)
**OS:** Ubuntu 24.04
**Network:** `lifeos_network` (Docker bridge, 172.21.0.0/16)
```
Internet
┌────────────────┐
│ Nginx (host) │ SSL termination
│ Port 80/443 │
└───────┬────────┘
┌────────────┴────────────┐
│ │
▼ ▼
┌─────────────────┐ ┌──────────────────┐
│ lifeos-dev │ │ lifeos-prod │
│ Port 8003 │ │ Port 8002 │
│ (hot reload) │ │ (not yet deployed)
└────────┬────────┘ └────────┬─────────┘
│ │
└───────────┬───────────┘
┌─────────────────┐
│ lifeos-db │
│ PostgreSQL 16 │
│ Port 5432 │
│ (internal) │
└─────────────────┘
```
---
## 5. Docker Configuration
### Dockerfile
```dockerfile
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN mkdir -p /opt/lifeos/files
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
```
### Container Run Commands
**Dev Container (hot reload, volume mount):**
```bash
docker run -d \
--name lifeos-dev \
--network lifeos_network \
--restart unless-stopped \
--env-file .env \
-p 8003:8003 \
-v /opt/lifeos/dev/files:/opt/lifeos/files/dev \
-v /opt/lifeos/dev:/app \
lifeos-app \
uvicorn main:app --host 0.0.0.0 --port 8003 --workers 1 --reload
```
The volume mount `-v /opt/lifeos/dev:/app` means any file change on the host is immediately visible inside the container. Combined with `--reload`, uvicorn detects changes and restarts automatically. No container rebuild needed.
**Database Container:**
```bash
docker run -d \
--name lifeos-db \
--network lifeos_network \
--restart unless-stopped \
-e POSTGRES_PASSWORD=UCTOQDZiUhN8U \
-v lifeos_pgdata:/var/lib/postgresql/data \
postgres:16-alpine
```
### Environment Variables (.env)
```
DATABASE_URL=postgresql+asyncpg://postgres:UCTOQDZiUhN8U@lifeos-db:5432/lifeos_dev
FILE_STORAGE_PATH=/opt/lifeos/files/dev
ENVIRONMENT=development
```
---
## 6. Nginx & SSL
**Config location:** `/etc/nginx/sites-available/invixiom`
**Domain routing:**
- `lifeos-dev.invixiom.com``localhost:8003` (dev)
- `lifeos.invixiom.com``localhost:8002` (prod, pending)
**SSL certificate path:** `/etc/letsencrypt/live/kasm.invixiom.com-0001/`
Note: The cert uses the `kasm.invixiom.com-0001` path (not `kasm.invixiom.com`). This is a known quirk of the Let's Encrypt renewal structure on this server.
---
## 7. Database Configuration
**Three databases in lifeos-db:**
| Database | Purpose | Used By |
|----------|---------|---------|
| `lifeos_dev` | Active development | lifeos-dev container |
| `lifeos_prod` | Production data (DO NOT TOUCH) | lifeos-prod container |
| `lifeos_test` | Test suite | pytest via `tests/conftest.py` |
**Connection pooling (SQLAlchemy):**
```python
engine = create_async_engine(
DATABASE_URL,
echo=os.getenv("ENVIRONMENT") == "development",
pool_size=5,
max_overflow=10,
pool_pre_ping=True,
)
```
**Session management:**
```python
async_session_factory = async_sessionmaker(
engine, class_=AsyncSession, expire_on_commit=False,
)
async def get_db():
async with async_session_factory() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
```
---
# Part 3: Database Schema
## 8. Schema Conventions
Every table follows these universal conventions:
### Standard Column Set
```sql
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- [entity-specific columns]
tags TEXT[], -- Array of string tags
sort_order INT NOT NULL DEFAULT 0, -- Manual ordering
is_deleted BOOL NOT NULL DEFAULT false, -- Soft delete flag
deleted_at TIMESTAMPTZ, -- When soft-deleted
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- Auto-set on insert
updated_at TIMESTAMPTZ NOT NULL DEFAULT now() -- Auto-set on update
```
### Searchable Tables Add
```sql
search_vector TSVECTOR -- Full-text search column
-- Plus GIN index:
CREATE INDEX idx_tablename_search ON tablename USING GIN(search_vector);
-- Plus trigger:
CREATE TRIGGER trg_tablename_search
BEFORE INSERT OR UPDATE ON tablename
FOR EACH ROW EXECUTE FUNCTION update_tablename_search();
```
### Soft Delete Pattern
- `is_deleted = false` filter applied automatically by BaseRepository
- `deleted_at` timestamp records when deletion occurred
- `soft_delete()` sets both fields
- `restore()` clears both fields
- `permanent_delete()` does actual SQL DELETE (admin only)
### Foreign Key Conventions
- Required parent references use `ON DELETE CASCADE`
- Optional references use `ON DELETE SET NULL`
- Junction tables use composite PKs with CASCADE on both sides
---
## 9. Core Entity Tables (31 tables)
### domains
Life areas at the highest level (e.g., "Career", "Health", "Family").
```sql
CREATE TABLE domains (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
color TEXT, -- Hex color for UI badges
description TEXT,
icon TEXT,
sort_order INT NOT NULL DEFAULT 0,
is_deleted BOOL NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
search_vector TSVECTOR,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_domains_search ON domains USING GIN(search_vector);
CREATE INDEX idx_domains_sort ON domains USING btree(sort_order);
```
**Search trigger:** Indexes `name`.
### areas
Subcategories within domains (e.g., "Software Engineering" under "Career").
```sql
CREATE TABLE areas (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
domain_id UUID NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
icon TEXT,
color TEXT,
status TEXT NOT NULL DEFAULT 'active',
sort_order INT NOT NULL DEFAULT 0,
is_deleted BOOL NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
search_vector TSVECTOR,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_areas_search ON areas USING GIN(search_vector);
CREATE INDEX idx_areas_sort ON areas USING btree(domain_id, sort_order);
```
**Search trigger:** Indexes `name`, `description`.
### projects
Concrete initiatives with status tracking and timelines.
```sql
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
domain_id UUID NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
area_id UUID REFERENCES areas(id) ON DELETE SET NULL,
name TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL DEFAULT 'active', -- active|on_hold|completed|archived
priority INT NOT NULL DEFAULT 3, -- 1=critical, 2=high, 3=normal, 4=low
start_date DATE,
target_date DATE,
completed_at TIMESTAMPTZ,
color TEXT,
tags TEXT[],
sort_order INT NOT NULL DEFAULT 0,
is_deleted BOOL NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
search_vector TSVECTOR,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_projects_search ON projects USING GIN(search_vector);
CREATE INDEX idx_projects_sort ON projects USING btree(domain_id, sort_order);
CREATE INDEX idx_projects_area_sort ON projects USING btree(area_id, sort_order);
CREATE INDEX idx_projects_status ON projects USING btree(status);
```
**Search trigger:** Indexes `name`, `description`, `tags`.
### tasks
The core work unit. Supports subtasks, contexts, time estimates, and status workflow.
```sql
CREATE TABLE tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
domain_id UUID REFERENCES domains(id) ON DELETE CASCADE,
area_id UUID REFERENCES areas(id) ON DELETE SET NULL,
project_id UUID REFERENCES projects(id) ON DELETE SET NULL,
release_id UUID REFERENCES releases(id) ON DELETE SET NULL,
parent_id UUID REFERENCES tasks(id) ON DELETE SET NULL, -- Subtask support
title TEXT NOT NULL,
description TEXT,
priority INT NOT NULL DEFAULT 3, -- 1=critical..4=low
status TEXT NOT NULL DEFAULT 'open', -- open|in_progress|blocked|done|cancelled
due_date DATE,
deadline TIMESTAMPTZ,
recurrence TEXT,
estimated_minutes INT,
energy_required TEXT, -- high|medium|low
context TEXT, -- @home, @office, @errands, etc.
is_custom_context BOOL NOT NULL DEFAULT false,
waiting_for_contact_id UUID REFERENCES contacts(id) ON DELETE SET NULL,
waiting_since DATE,
import_batch_id UUID,
tags TEXT[],
sort_order INT NOT NULL DEFAULT 0,
is_deleted BOOL NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
search_vector TSVECTOR,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_tasks_search ON tasks USING GIN(search_vector);
CREATE INDEX idx_tasks_status ON tasks USING btree(status);
CREATE INDEX idx_tasks_priority ON tasks USING btree(priority);
CREATE INDEX idx_tasks_due_date ON tasks USING btree(due_date);
CREATE INDEX idx_tasks_domain_sort ON tasks USING btree(domain_id, sort_order);
CREATE INDEX idx_tasks_project_sort ON tasks USING btree(project_id, sort_order);
CREATE INDEX idx_tasks_parent_sort ON tasks USING btree(parent_id, sort_order);
```
**Search trigger:** Indexes `title`, `description`, `tags`.
### notes
Markdown-based notes linked to domains, projects, tasks, meetings, or folders.
```sql
CREATE TABLE notes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
domain_id UUID REFERENCES domains(id) ON DELETE CASCADE,
project_id UUID REFERENCES projects(id) ON DELETE SET NULL,
folder_id UUID REFERENCES note_folders(id) ON DELETE SET NULL,
meeting_id UUID REFERENCES meetings(id) ON DELETE SET NULL,
task_id UUID REFERENCES tasks(id) ON DELETE SET NULL,
title TEXT NOT NULL,
body TEXT,
content_format TEXT NOT NULL DEFAULT 'rich',
is_meeting_note BOOL NOT NULL DEFAULT false,
tags TEXT[],
sort_order INT NOT NULL DEFAULT 0,
is_deleted BOOL NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
search_vector TSVECTOR,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_notes_search ON notes USING GIN(search_vector);
CREATE INDEX idx_notes_task_id ON notes USING btree(task_id);
```
**Search trigger:** Indexes `title`, `body`, `tags`.
### note_folders
Hierarchical folder system for organizing notes.
```sql
CREATE TABLE note_folders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
parent_id UUID REFERENCES note_folders(id) ON DELETE CASCADE,
name TEXT NOT NULL,
auto_generated BOOL NOT NULL DEFAULT false,
sort_order INT NOT NULL DEFAULT 0,
is_deleted BOOL NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
```
### lists
Checklists and general-purpose lists linked to domains, projects, tasks, or meetings.
```sql
CREATE TABLE lists (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
domain_id UUID REFERENCES domains(id) ON DELETE CASCADE,
area_id UUID REFERENCES areas(id) ON DELETE SET NULL,
project_id UUID REFERENCES projects(id) ON DELETE SET NULL,
task_id UUID REFERENCES tasks(id) ON DELETE SET NULL,
meeting_id UUID REFERENCES meetings(id) ON DELETE SET NULL,
name TEXT NOT NULL,
list_type TEXT NOT NULL DEFAULT 'checklist',
description TEXT,
tags TEXT[],
sort_order INT NOT NULL DEFAULT 0,
is_deleted BOOL NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
search_vector TSVECTOR,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_lists_search ON lists USING GIN(search_vector);
CREATE INDEX idx_lists_task_id ON lists USING btree(task_id);
CREATE INDEX idx_lists_meeting_id ON lists USING btree(meeting_id);
```
**Search trigger:** Indexes `name`, `description`, `tags`.
### list_items
Individual items within a list. Supports nesting via `parent_item_id`.
```sql
CREATE TABLE list_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
list_id UUID NOT NULL REFERENCES lists(id) ON DELETE CASCADE,
parent_item_id UUID REFERENCES list_items(id) ON DELETE SET NULL,
content TEXT NOT NULL,
completed BOOL NOT NULL DEFAULT false,
completed_at TIMESTAMPTZ,
sort_order INT NOT NULL DEFAULT 0,
is_deleted BOOL NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_list_items_sort ON list_items USING btree(list_id, sort_order);
CREATE INDEX idx_list_items_parent_sort ON list_items USING btree(parent_item_id, sort_order);
```
### links
Quick-access bookmarks organized by domain/area/project.
```sql
CREATE TABLE links (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
domain_id UUID REFERENCES domains(id) ON DELETE CASCADE,
area_id UUID REFERENCES areas(id) ON DELETE SET NULL,
project_id UUID REFERENCES projects(id) ON DELETE SET NULL,
label TEXT NOT NULL,
url TEXT NOT NULL,
description TEXT,
tags TEXT[],
sort_order INT NOT NULL DEFAULT 0,
is_deleted BOOL NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
search_vector TSVECTOR,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_links_search ON links USING GIN(search_vector);
```
**Search trigger:** Indexes `label`, `url`, `description`, `tags`.
### files
Uploaded file metadata. Actual files stored on disk at `FILE_STORAGE_PATH`.
```sql
CREATE TABLE files (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
filename TEXT NOT NULL, -- Stored filename (UUID-based)
original_filename TEXT NOT NULL, -- User's original filename
storage_path TEXT NOT NULL, -- Full disk path
mime_type TEXT,
size_bytes INT,
description TEXT,
tags TEXT[],
sort_order INT NOT NULL DEFAULT 0,
is_deleted BOOL NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
search_vector TSVECTOR,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_files_search ON files USING GIN(search_vector);
```
**Search trigger:** Indexes `original_filename`, `description`, `tags`.
### weblinks
Similar to links but with task/meeting association and folder organization.
```sql
CREATE TABLE weblinks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
label TEXT NOT NULL,
url TEXT NOT NULL,
description TEXT,
task_id UUID REFERENCES tasks(id) ON DELETE SET NULL,
meeting_id UUID REFERENCES meetings(id) ON DELETE SET NULL,
tags TEXT[],
sort_order INT NOT NULL DEFAULT 0,
is_deleted BOOL NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
search_vector TSVECTOR,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_weblinks_search ON weblinks USING GIN(search_vector);
CREATE INDEX idx_weblinks_task_id ON weblinks USING btree(task_id);
CREATE INDEX idx_weblinks_meeting_id ON weblinks USING btree(meeting_id);
```
**Search trigger:** Indexes `label`, `url`, `description`, `tags`.
### weblink_folders
Hierarchical folder system for organizing weblinks.
```sql
CREATE TABLE weblink_folders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
parent_id UUID REFERENCES weblink_folders(id) ON DELETE CASCADE,
name TEXT NOT NULL,
auto_generated BOOL NOT NULL DEFAULT false,
sort_order INT NOT NULL DEFAULT 0,
is_deleted BOOL NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_weblink_folders_sort ON weblink_folders USING btree(parent_id, sort_order);
```
### contacts
People linked to tasks, projects, meetings, and decisions.
```sql
CREATE TABLE contacts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
first_name TEXT NOT NULL,
last_name TEXT,
company TEXT,
role TEXT,
email TEXT,
phone TEXT,
notes TEXT,
tags TEXT[],
sort_order INT NOT NULL DEFAULT 0,
is_deleted BOOL NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
search_vector TSVECTOR,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_contacts_search ON contacts USING GIN(search_vector);
```
**Search trigger:** Indexes `first_name`, `last_name`, `company`, `email`, `tags`.
### appointments
Calendar events with start/end times and recurrence.
```sql
CREATE TABLE appointments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
description TEXT,
location TEXT,
start_at TIMESTAMPTZ NOT NULL,
end_at TIMESTAMPTZ,
all_day BOOL NOT NULL DEFAULT false,
recurrence TEXT,
tags TEXT[],
sort_order INT NOT NULL DEFAULT 0,
is_deleted BOOL NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
search_vector TSVECTOR,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_appointments_search ON appointments USING GIN(search_vector);
CREATE INDEX idx_appointments_start ON appointments USING btree(start_at);
```
**Search trigger:** Indexes `title`, `description`, `location`, `tags`.
### meetings
Meetings with agenda, transcript, notes, and linked action items.
```sql
CREATE TABLE meetings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
parent_id UUID REFERENCES meetings(id) ON DELETE SET NULL, -- Recurring meeting series
title TEXT NOT NULL,
meeting_date DATE NOT NULL,
start_at TIMESTAMPTZ,
end_at TIMESTAMPTZ,
location TEXT,
status TEXT NOT NULL DEFAULT 'scheduled', -- scheduled|completed|cancelled
priority INT,
recurrence TEXT,
agenda TEXT,
transcript TEXT,
notes_body TEXT,
tags TEXT[],
sort_order INT NOT NULL DEFAULT 0,
is_deleted BOOL NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
search_vector TSVECTOR,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_meetings_search ON meetings USING GIN(search_vector);
CREATE INDEX idx_meetings_date ON meetings USING btree(meeting_date);
```
**Search trigger:** Indexes `title`, `agenda`, `notes_body`, `tags`.
### decisions
Decision records with rationale, impact, status, and optional supersession chain.
```sql
CREATE TABLE decisions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
rationale TEXT,
status TEXT NOT NULL DEFAULT 'proposed', -- proposed|accepted|rejected|superseded
impact TEXT NOT NULL DEFAULT 'medium', -- low|medium|high
decided_at DATE,
task_id UUID REFERENCES tasks(id) ON DELETE SET NULL,
meeting_id UUID REFERENCES meetings(id) ON DELETE SET NULL,
superseded_by_id UUID REFERENCES decisions(id) ON DELETE SET NULL,
tags TEXT[],
sort_order INT NOT NULL DEFAULT 0,
is_deleted BOOL NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
search_vector TSVECTOR,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_decisions_search ON decisions USING GIN(search_vector);
CREATE INDEX idx_decisions_task_id ON decisions USING btree(task_id);
```
**Search trigger:** Indexes `title`, `rationale`, `tags`.
### time_entries
Time tracking records. **Note:** No `updated_at` column - this is a known exception.
```sql
CREATE TABLE time_entries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
start_at TIMESTAMPTZ NOT NULL,
end_at TIMESTAMPTZ, -- NULL = timer is running
duration_minutes INT,
notes TEXT,
is_deleted BOOL NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
-- NO updated_at column! BaseRepository.soft_delete() will fail. Use direct SQL.
);
CREATE INDEX idx_time_entries_task ON time_entries USING btree(task_id);
```
### time_blocks
Scheduled time blocks for planning (distinct from time_entries which track actual work).
```sql
CREATE TABLE time_blocks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
task_id UUID REFERENCES tasks(id) ON DELETE SET NULL,
title TEXT NOT NULL,
context TEXT,
energy TEXT,
start_at TIMESTAMPTZ NOT NULL,
end_at TIMESTAMPTZ NOT NULL,
is_deleted BOOL NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
```
### time_budgets
Weekly hour allocations per domain with effective dating.
```sql
CREATE TABLE time_budgets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
domain_id UUID NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
weekly_hours NUMERIC NOT NULL,
effective_from DATE NOT NULL,
sort_order INT NOT NULL DEFAULT 0,
is_deleted BOOL NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
```
### daily_focus
Links tasks to specific dates as "focus items" for that day.
```sql
CREATE TABLE daily_focus (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
focus_date DATE NOT NULL,
task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
slot INT,
completed BOOL NOT NULL DEFAULT false,
note TEXT,
sort_order INT NOT NULL DEFAULT 0,
is_deleted BOOL NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_daily_focus_date ON daily_focus USING btree(focus_date);
```
### processes
Template definitions for repeatable workflows/checklists.
```sql
CREATE TABLE processes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
description TEXT,
process_type TEXT NOT NULL DEFAULT 'checklist', -- workflow|checklist
category TEXT,
status TEXT NOT NULL DEFAULT 'draft', -- draft|active|archived
tags TEXT[],
sort_order INT NOT NULL DEFAULT 0,
is_deleted BOOL NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
search_vector TSVECTOR,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_processes_search ON processes USING GIN(search_vector);
```
**Search trigger:** Indexes `name`, `description`, `tags`.
### process_steps
Individual steps within a process template.
```sql
CREATE TABLE process_steps (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
process_id UUID NOT NULL REFERENCES processes(id) ON DELETE CASCADE,
title TEXT NOT NULL,
instructions TEXT,
expected_output TEXT,
estimated_days INT,
context TEXT,
sort_order INT NOT NULL DEFAULT 0,
is_deleted BOOL NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
```
### process_runs
Instances of running a process. Copies steps as immutable snapshots.
```sql
CREATE TABLE process_runs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
process_id UUID NOT NULL REFERENCES processes(id) ON DELETE CASCADE,
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'not_started', -- not_started|in_progress|completed|cancelled
process_type TEXT NOT NULL,
task_generation TEXT NOT NULL DEFAULT 'all_at_once', -- all_at_once|step_by_step
project_id UUID REFERENCES projects(id) ON DELETE SET NULL,
contact_id UUID REFERENCES contacts(id) ON DELETE SET NULL,
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
sort_order INT NOT NULL DEFAULT 0,
is_deleted BOOL NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
```
### process_run_steps
Immutable step copies within a run, with completion tracking.
```sql
CREATE TABLE process_run_steps (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
run_id UUID NOT NULL REFERENCES process_runs(id) ON DELETE CASCADE,
title TEXT NOT NULL,
instructions TEXT,
status TEXT NOT NULL DEFAULT 'pending', -- pending|in_progress|completed|skipped
completed_by_id UUID REFERENCES contacts(id) ON DELETE SET NULL,
completed_at TIMESTAMPTZ,
notes TEXT,
sort_order INT NOT NULL DEFAULT 0,
is_deleted BOOL NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
```
### capture
Quick-capture inbox for unprocessed thoughts/items.
```sql
CREATE TABLE capture (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
raw_text TEXT NOT NULL,
processed BOOL NOT NULL DEFAULT false,
converted_to_type TEXT, -- task|note|project|contact|decision|weblink|list_item
converted_to_id UUID,
area_id UUID REFERENCES areas(id) ON DELETE SET NULL,
project_id UUID REFERENCES projects(id) ON DELETE SET NULL,
list_id UUID REFERENCES lists(id) ON DELETE SET NULL,
import_batch_id UUID,
sort_order INT NOT NULL DEFAULT 0,
is_deleted BOOL NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_capture_processed ON capture USING btree(processed);
```
### context_types
System-defined context labels for tasks (e.g., @home, @office, @errands).
```sql
CREATE TABLE context_types (
id SERIAL PRIMARY KEY,
value TEXT NOT NULL UNIQUE,
label TEXT NOT NULL,
description TEXT,
is_system BOOL NOT NULL DEFAULT true,
sort_order INT NOT NULL DEFAULT 0,
is_deleted BOOL NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
```
### reminders
Future-scheduled reminders linked to any entity type.
```sql
CREATE TABLE reminders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
entity_type TEXT NOT NULL,
entity_id UUID NOT NULL,
remind_at TIMESTAMPTZ NOT NULL,
note TEXT,
delivered BOOL NOT NULL DEFAULT false,
is_deleted BOOL NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_reminders_entity ON reminders USING btree(entity_type, entity_id);
```
### dependencies
Generic dependency relationships between any entity types. Supports cycle detection (Phase 2).
```sql
CREATE TABLE dependencies (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
blocker_type TEXT NOT NULL,
blocker_id UUID NOT NULL,
dependent_type TEXT NOT NULL,
dependent_id UUID NOT NULL,
dependency_type TEXT NOT NULL DEFAULT 'finish_to_start',
lag_days INT NOT NULL DEFAULT 0,
note TEXT,
is_deleted BOOL NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (blocker_type, blocker_id, dependent_type, dependent_id, dependency_type),
CHECK (NOT (blocker_type = dependent_type AND blocker_id = dependent_id))
);
CREATE INDEX idx_dependencies_blocker ON dependencies USING btree(blocker_type, blocker_id);
CREATE INDEX idx_dependencies_dependent ON dependencies USING btree(dependent_type, dependent_id);
```
### releases
Versioned release milestones spanning multiple projects.
```sql
CREATE TABLE releases (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
version_label TEXT,
description TEXT,
status TEXT NOT NULL DEFAULT 'planned', -- planned|in_progress|released|cancelled
target_date DATE,
released_at DATE,
release_notes TEXT,
tags TEXT[],
sort_order INT NOT NULL DEFAULT 0,
is_deleted BOOL NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
search_vector TSVECTOR,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
```
**Search trigger:** Indexes `name`, `description`, `version_label`, `tags`.
### milestones
Date-based checkpoints within releases or projects.
```sql
CREATE TABLE milestones (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
release_id UUID REFERENCES releases(id) ON DELETE SET NULL,
project_id UUID REFERENCES projects(id) ON DELETE SET NULL,
name TEXT NOT NULL,
target_date DATE NOT NULL,
completed_at DATE,
sort_order INT NOT NULL DEFAULT 0,
is_deleted BOOL NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
```
### task_templates
Reusable task templates with preset fields.
```sql
CREATE TABLE task_templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
description TEXT,
priority INT,
estimated_minutes INT,
energy_required TEXT,
context TEXT,
tags TEXT[],
sort_order INT NOT NULL DEFAULT 0,
is_deleted BOOL NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
```
### task_template_items
Individual sub-items within a task template.
```sql
CREATE TABLE task_template_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
template_id UUID NOT NULL REFERENCES task_templates(id) ON DELETE CASCADE,
title TEXT NOT NULL,
sort_order INT NOT NULL DEFAULT 0,
is_deleted BOOL NOT NULL DEFAULT false,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
```
---
## 10. Junction Tables (16 tables)
All junction tables use composite primary keys and CASCADE deletes on both sides.
```sql
-- Note <-> Project (many-to-many)
CREATE TABLE note_projects (
note_id UUID NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
is_primary BOOL NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (note_id, project_id)
);
-- Note <-> Note (wiki-style links)
CREATE TABLE note_links (
source_note_id UUID NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
target_note_id UUID NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (source_note_id, target_note_id)
);
-- File <-> Any Entity (polymorphic mapping)
CREATE TABLE file_mappings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
file_id UUID NOT NULL REFERENCES files(id) ON DELETE CASCADE,
context_type TEXT NOT NULL, -- 'task', 'project', 'note', etc.
context_id UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (file_id, context_type, context_id)
);
CREATE INDEX idx_file_mappings_context ON file_mappings USING btree(context_type, context_id);
-- Release <-> Project
CREATE TABLE release_projects (
release_id UUID NOT NULL REFERENCES releases(id) ON DELETE CASCADE,
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (release_id, project_id)
);
-- Release <-> Domain
CREATE TABLE release_domains (
release_id UUID NOT NULL REFERENCES releases(id) ON DELETE CASCADE,
domain_id UUID NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (release_id, domain_id)
);
-- Contact <-> Task
CREATE TABLE contact_tasks (
contact_id UUID NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
role TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (contact_id, task_id)
);
-- Contact <-> Project
CREATE TABLE contact_projects (
contact_id UUID NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
role TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (contact_id, project_id)
);
-- Contact <-> List
CREATE TABLE contact_lists (
contact_id UUID NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
list_id UUID NOT NULL REFERENCES lists(id) ON DELETE CASCADE,
role TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (contact_id, list_id)
);
-- Contact <-> List Item
CREATE TABLE contact_list_items (
contact_id UUID NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
list_item_id UUID NOT NULL REFERENCES list_items(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (contact_id, list_item_id)
);
-- Contact <-> Appointment
CREATE TABLE contact_appointments (
contact_id UUID NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
appointment_id UUID NOT NULL REFERENCES appointments(id) ON DELETE CASCADE,
role TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (contact_id, appointment_id)
);
-- Contact <-> Meeting
CREATE TABLE contact_meetings (
contact_id UUID NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
meeting_id UUID NOT NULL REFERENCES meetings(id) ON DELETE CASCADE,
role TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (contact_id, meeting_id)
);
-- Decision <-> Project
CREATE TABLE decision_projects (
decision_id UUID NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (decision_id, project_id)
);
-- Decision <-> Contact
CREATE TABLE decision_contacts (
decision_id UUID NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
contact_id UUID NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
role TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (decision_id, contact_id)
);
-- Meeting <-> Task (action items)
CREATE TABLE meeting_tasks (
meeting_id UUID NOT NULL REFERENCES meetings(id) ON DELETE CASCADE,
task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
source TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (meeting_id, task_id)
);
-- Process Run Step <-> Task
CREATE TABLE process_run_tasks (
run_step_id UUID NOT NULL REFERENCES process_run_steps(id) ON DELETE CASCADE,
task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (run_step_id, task_id)
);
-- Folder <-> Weblink
CREATE TABLE folder_weblinks (
folder_id UUID NOT NULL REFERENCES weblink_folders(id) ON DELETE CASCADE,
weblink_id UUID NOT NULL REFERENCES weblinks(id) ON DELETE CASCADE,
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (folder_id, weblink_id)
);
```
---
## 11. Database Functions & Triggers
### Search Vector Update Functions
15 entity-specific search functions, each following the same pattern. The function builds a tsvector from relevant text columns and stores it in `search_vector`.
**Pattern:**
```sql
CREATE OR REPLACE FUNCTION update_{table}_search() RETURNS TRIGGER AS $$
BEGIN
NEW.search_vector := to_tsvector('pg_catalog.english',
coalesce(NEW.{col1}, '') || ' ' || coalesce(NEW.{col2}, '') || ' ' ||
coalesce(array_to_string(NEW.tags, ' '), ''));
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_{table}_search
BEFORE INSERT OR UPDATE ON {table}
FOR EACH ROW EXECUTE FUNCTION update_{table}_search();
```
**Per-table indexed columns:**
| Function | Table | Indexed Columns |
|----------|-------|----------------|
| `update_domains_search` | domains | name |
| `update_areas_search` | areas | name, description |
| `update_projects_search` | projects | name, description, tags |
| `update_tasks_search` | tasks | title, description, tags |
| `update_notes_search` | notes | title, body, tags |
| `update_contacts_search` | contacts | first_name, last_name, company, email, tags |
| `update_meetings_search` | meetings | title, agenda, notes_body, tags |
| `update_decisions_search` | decisions | title, rationale, tags |
| `update_links_search` | links | label, url, description, tags |
| `update_weblinks_search` | weblinks | label, url, description, tags |
| `update_files_search` | files | original_filename, description, tags |
| `update_lists_search` | lists | name, description, tags |
| `update_appointments_search` | appointments | title, description, location, tags |
| `update_processes_search` | processes | name, description, tags |
| `update_releases_search` | releases | name, description, version_label, tags |
There is also a generic `update_search_vector()` function as a fallback that attempts to index `title`, `description`, `name`, and `tags` with exception handling for missing columns.
---
## 12. Entity Relationship Map
```
domains (1)─────────────┬──────(M) areas
│ │ │
│ │ └──(M) projects ◄──── areas.area_id
│ │ │
├──(M) projects │ ├──(M) tasks
├──(M) tasks │ ├──(M) notes
├──(M) notes │ ├──(M) links
├──(M) links │ ├──(M) lists
├──(M) lists │ └──(M) milestones
└──(M) time_budgets │
tasks ──────────────────┬──(M) time_entries
│ ├──(M) daily_focus
│ ├──(M) notes (via task_id)
│ ├──(M) lists (via task_id)
│ ├──(M) weblinks (via task_id)
│ ├──(M) decisions (via task_id)
│ ├──(M) subtasks (self-ref via parent_id)
│ └──(M) time_blocks
contacts ───────────────┬──(M) contact_tasks ──── tasks
│ ├──(M) contact_projects ── projects
│ ├──(M) contact_meetings ── meetings
│ ├──(M) contact_appointments ── appointments
│ ├──(M) contact_lists ──── lists
│ └──(M) decision_contacts ── decisions
meetings ───────────────┬──(M) meeting_tasks ──── tasks
│ ├──(M) contact_meetings ── contacts
│ ├──(M) notes (via meeting_id)
│ ├──(M) lists (via meeting_id)
│ ├──(M) weblinks (via meeting_id)
│ └──(M) decisions (via meeting_id)
processes ──────────────┬──(M) process_steps
│ └──(M) process_runs
│ ├──(M) process_run_steps
│ └──(M) process_run_tasks ── tasks
files ──────────────────── file_mappings ──── (polymorphic: any entity)
releases ───────────────┬──(M) release_projects ── projects
│ ├──(M) release_domains ── domains
│ ├──(M) tasks (via release_id)
│ └──(M) milestones
```
---
# Part 4: Backend Application
## 13. Application Bootstrap (main.py)
### Initialization Flow
```python
# 1. Load environment variables
load_dotenv()
# 2. Create FastAPI app with lifespan
app = FastAPI(title="Life OS", version="1.0.0", lifespan=lifespan)
# 3. Mount static files
app.mount("/static", StaticFiles(directory="static"), name="static")
# 4. Add ASGI middleware for environment context
app.add_middleware(RequestContextMiddleware)
# 5. Define dashboard route (/)
# 6. Define health check (/health)
# 7. Include all 23 routers
```
### Lifespan Handler
```python
@asynccontextmanager
async def lifespan(app: FastAPI):
ok = await check_db() # Verify DB connectivity at startup
print("Database connection OK" if ok else "WARNING: Database check failed")
yield
```
### RequestContextMiddleware
Pure ASGI middleware (not BaseHTTPMiddleware, which causes TaskGroup issues with asyncpg):
```python
class RequestContextMiddleware:
async def __call__(self, scope, receive, send):
if scope["type"] == "http":
scope.setdefault("state", {})["environment"] = self.environment
await self.app(scope, receive, send)
```
### Dashboard Route (/)
The dashboard queries:
1. Today's focus items (daily_focus JOIN tasks, projects, domains)
2. Overdue tasks (due_date < today, not done/cancelled, limit 10)
3. Upcoming tasks (due in next 7 days, limit 10)
4. Task stats (open count, done this week, in progress)
### Router Registration Order
23 routers included in this order:
1. domains, areas, projects, tasks, notes, links
2. focus, capture, contacts, search, admin, lists
3. files, meetings, decisions, weblinks
4. appointments, time_tracking, processes, calendar
5. time_budgets, eisenhower, history
---
## 14. Core Modules
### database.py - Connection Management
**Engine Configuration:**
```python
engine = create_async_engine(
DATABASE_URL,
echo=(ENVIRONMENT == "development"), # SQL logging in dev
pool_size=5,
max_overflow=10,
pool_pre_ping=True, # Verify connections before use
)
```
**Session Factory:**
```python
async_session_factory = async_sessionmaker(
engine, class_=AsyncSession, expire_on_commit=False,
)
```
**get_db() Dependency:**
FastAPI dependency that yields an async session with auto-commit/rollback:
```python
async def get_db():
async with async_session_factory() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
```
### base_repository.py - Generic CRUD
The `BaseRepository` class provides all CRUD operations for any table. It uses raw SQL via `text()` - no ORM models.
**Constructor:** `BaseRepository(table_name: str, db: AsyncSession)`
**Methods:**
| Method | Signature | Behavior |
|--------|-----------|----------|
| `list()` | `(filters, sort, sort_dir, page, per_page, include_deleted)` | SELECT with auto `is_deleted=false` filter. Supports pagination, sorting, and key-value filters. Special filter values: `None``IS NULL`, `"__notnull__"``IS NOT NULL`. |
| `count()` | `(filters, include_deleted)` | COUNT with same filter logic |
| `get()` | `(id: UUID)` | SELECT by ID. Validates UUID format. Returns dict or None. |
| `create()` | `(data: dict)` | INSERT with auto `created_at`, `updated_at`, `is_deleted=false`. Coerces ISO date strings to Python date/datetime (required by asyncpg). Returns full row. |
| `update()` | `(id: UUID, data: dict)` | UPDATE with auto `updated_at`. **Nullable fields gotcha:** Only fields listed in `nullable_fields` set can be set to NULL. All other NULL values are silently skipped. |
| `soft_delete()` | `(id: UUID)` | Sets `is_deleted=true`, `deleted_at=now()`, `updated_at=now()` |
| `restore()` | `(id: UUID)` | Sets `is_deleted=false`, `deleted_at=NULL`, `updated_at=now()` |
| `permanent_delete()` | `(id: UUID)` | Actual SQL DELETE. Admin only. |
| `bulk_soft_delete()` | `(ids: list[str])` | Batch soft delete |
| `list_deleted()` | `()` | Lists all soft-deleted rows, ordered by deleted_at DESC |
| `reorder()` | `(id_order: list[str])` | Updates sort_order based on position (10, 20, 30...) |
**Nullable Fields Set:**
```python
nullable_fields = {
"description", "notes", "body", "area_id", "project_id",
"parent_id", "parent_item_id", "release_id", "due_date", "deadline", "tags",
"context", "folder_id", "meeting_id", "completed_at",
"waiting_for_contact_id", "waiting_since", "color",
"rationale", "decided_at", "superseded_by_id",
"start_at", "end_at", "location", "agenda", "transcript", "notes_body",
"priority", "recurrence", "mime_type",
"category", "instructions", "expected_output", "estimated_days",
"contact_id", "started_at",
"weekly_hours", "effective_from",
"task_id", "meeting_id",
}
```
**Date Coercion:**
asyncpg requires native Python `date`/`datetime` objects, not strings. The `_coerce_value()` helper auto-converts ISO format strings (`YYYY-MM-DD`, `YYYY-MM-DDTHH:MM`) to Python types.
### sidebar.py - Navigation Data
Builds the domain > area > project tree for the sidebar. Called by every route.
```python
async def get_sidebar_data(db: AsyncSession) -> dict:
# Returns:
{
"domain_tree": [
{
"id": UUID, "name": str, "color": str,
"areas": [
{"id": UUID, "name": str, "projects": [...]}
],
"standalone_projects": [...] # Projects with no area
}
],
"capture_count": int, # Unprocessed capture items
"focus_count": int, # Incomplete focus items for today
}
```
---
## 15. Router Pattern
Every router follows this exact structure:
```python
from fastapi import APIRouter, Request, Form, Depends
from fastapi.responses import RedirectResponse
from fastapi.templating import Jinja2Templates
from core.base_repository import BaseRepository
from core.database import get_db
from core.sidebar import get_sidebar_data
router = APIRouter(prefix='/things', tags=['things'])
templates = Jinja2Templates(directory='templates')
# LIST - GET /things/
@router.get('/')
async def list_things(request: Request, db=Depends(get_db)):
repo = BaseRepository("things", db)
items = await repo.list()
sidebar = await get_sidebar_data(db)
return templates.TemplateResponse('things.html', {
'request': request, 'items': items, 'sidebar': sidebar,
'page_title': 'Things', 'active_nav': 'things',
})
# CREATE FORM - GET /things/create
@router.get('/create')
async def create_form(request: Request, db=Depends(get_db)):
sidebar = await get_sidebar_data(db)
return templates.TemplateResponse('thing_form.html', {
'request': request, 'item': None, 'sidebar': sidebar,
})
# CREATE - POST /things/create
@router.post('/create')
async def create_thing(request: Request, db=Depends(get_db),
title: str = Form(...), description: str = Form(None)):
repo = BaseRepository("things", db)
item = await repo.create({'title': title, 'description': description})
return RedirectResponse(url=f'/things/{item["id"]}', status_code=303)
# DETAIL - GET /things/{id}
@router.get('/{thing_id}')
async def detail(request: Request, thing_id: str, db=Depends(get_db)):
repo = BaseRepository("things", db)
item = await repo.get(thing_id)
if not item:
return RedirectResponse(url='/things', status_code=303)
sidebar = await get_sidebar_data(db)
return templates.TemplateResponse('thing_detail.html', {
'request': request, 'item': item, 'sidebar': sidebar,
})
# EDIT FORM - GET /things/{id}/edit
@router.get('/{thing_id}/edit')
async def edit_form(request: Request, thing_id: str, db=Depends(get_db)):
repo = BaseRepository("things", db)
item = await repo.get(thing_id)
sidebar = await get_sidebar_data(db)
return templates.TemplateResponse('thing_form.html', {
'request': request, 'item': item, 'sidebar': sidebar,
})
# EDIT - POST /things/{id}/edit
@router.post('/{thing_id}/edit')
async def edit_thing(request: Request, thing_id: str, db=Depends(get_db),
title: str = Form(...), description: str = Form(None)):
repo = BaseRepository("things", db)
await repo.update(thing_id, {'title': title, 'description': description})
return RedirectResponse(url=f'/things/{thing_id}', status_code=303)
# DELETE - POST /things/{id}/delete
@router.post('/{thing_id}/delete')
async def delete_thing(request: Request, thing_id: str, db=Depends(get_db)):
repo = BaseRepository("things", db)
await repo.soft_delete(thing_id)
return RedirectResponse(url='/things', status_code=303)
```
**Every route MUST call `get_sidebar_data(db)` and pass `sidebar` to the template.**
---
## 16. Router Reference (23 routers)
### Standard CRUD Routers
These follow the exact pattern above (list, create form, create, detail, edit form, edit, delete):
| Router | Prefix | Table | Extra Endpoints |
|--------|--------|-------|-----------------|
| domains | /domains | domains | - |
| areas | /areas | areas | - |
| projects | /projects | projects | `GET /api/by-domain` (JSON, for dynamic dropdowns) |
| notes | /notes | notes | - |
| links | /links | links | - |
| contacts | /contacts | contacts | - |
| lists | /lists | lists | `POST /{id}/items/add`, `POST /items/{id}/toggle`, `POST /items/{id}/delete` |
| meetings | /meetings | meetings | `POST /{id}/create-action` (create task from meeting) |
| decisions | /decisions | decisions | - |
| weblinks | /weblinks | weblinks | `POST /folders/create`, folder management |
| appointments | /appointments | appointments | - |
| processes | /processes | processes | `POST /{id}/steps/add`, `POST /steps/{id}/edit`, `POST /{id}/start-run` |
| time_budgets | /time-budgets | time_budgets | - |
### Specialized Routers
**tasks** (`/tasks`) - Most complex router:
- Standard CRUD (list, create, detail, edit, delete)
- `POST /quick-add` - Minimal task creation (title + domain_id only)
- `POST /{id}/toggle` - Toggle done/open status
- `POST /{id}/complete` - Mark as done with completed_at
- `GET /` with filters: status, priority, domain_id, project_id, sort, context
- Detail page with tabs: overview, notes, weblinks, files, lists, decisions, contacts
- `POST /{id}/contacts/add` - Link contact to task
- `POST /{id}/contacts/{contact_id}/remove` - Unlink contact
**focus** (`/focus`) - Daily focus management:
- `GET /` - Show today's focus + available tasks
- `POST /add` - Add task to focus (task_id + focus_date)
- `POST /{id}/toggle` - Toggle focus item complete
- `POST /{id}/remove` - Remove from focus
**capture** (`/capture`) - Quick capture inbox:
- `GET /` - List unprocessed captures
- `POST /add` - Create capture (raw_text, supports multi-line splitting)
- `GET /{id}/convert` - Show conversion options
- `POST /{id}/convert/{type}` - Convert to task/note/project/contact/decision/weblink/list_item
- `POST /{id}/dismiss` - Mark as processed without converting
- `POST /batch/{batch_id}/undo` - Undo batch conversion
**search** (`/search`) - Global search:
- `GET /` - Search page with HTML results
- `GET /api` - JSON search API for modal (returns `{results: [...]}`)
- Searches across configurable `SEARCH_ENTITIES` dict
- Uses tsvector/tsquery with `plainto_tsquery()` for safety
**admin** (`/admin`) - Trash management:
- `GET /trash` - List soft-deleted items across all entities
- `POST /trash/{table}/{id}/restore` - Restore soft-deleted item
- `POST /trash/{table}/{id}/permanent-delete` - Hard delete
- `POST /trash/empty` - Empty all trash
**time_tracking** (`/time`) - Timer and time log:
- `GET /` - Time log page with day filter
- `POST /start` - Start timer for task (auto-stops any running timer)
- `POST /stop` - Stop running timer
- `POST /manual` - Create manual time entry
- `GET /running` - JSON endpoint returning running timer state
- `POST /{id}/delete` - Delete time entry (uses direct SQL, no BaseRepository)
**calendar** (`/calendar`) - Read-only unified calendar:
- `GET /` - Month view combining appointments, meetings, and tasks with due dates
- Query params: `year`, `month`
**eisenhower** (`/eisenhower`) - Eisenhower matrix view:
- `GET /` - 2x2 grid classifying tasks by priority (1-2=important) and urgency (due <=7 days)
- Query params: `domain_id`, `project_id`, `status`, `context`
**history** (`/history`) - Change history feed:
- `GET /` - Reverse-chronological feed of recently modified items across all entities
- Query params: `entity_type` filter
---
## 17. Business Logic
### Timer Constraints
- **Only one timer can run at a time.** Starting a new timer auto-stops any running timer.
- Running timer detected by: `SELECT * FROM time_entries WHERE end_at IS NULL AND is_deleted = false`
- Stop sets `end_at = now()` and computes `duration_minutes`
- The topbar timer pill polls `/time/running` every 30 seconds
### Capture Conversions
- Multi-line `raw_text` is split on newlines, creating multiple capture items
- Each line in a batch gets the same `import_batch_id`
- Single-line captures get no batch_id
- Conversion creates the target entity and marks capture as `processed=true`
- Batch undo reverses all conversions for a batch_id
### Task Status Transitions
- Toggle: `open``done` (sets/clears `completed_at`)
- Complete action: Sets `status=done`, `completed_at=now()`
- Edit can change to any valid status
- Setting status to `done` auto-sets `completed_at`
- Setting status from `done` to anything else clears `completed_at`
### Focus Sync
- Adding a task to daily_focus doesn't change its status
- Toggling a focus item as complete marks `completed=true` on the daily_focus row
- Focus items for today are shown on the dashboard
### Search Algorithm
- Uses PostgreSQL `plainto_tsquery('english', query)` for safe query parsing
- Searches `search_vector @@ query` with tsvector ranking
- Results grouped by entity type with configurable display columns
- No special characters interpreted (safe from injection)
---
## 18. Key Enums & Constants
| Field | Valid Values | Default |
|-------|-------------|---------|
| Task status | `open`, `in_progress`, `blocked`, `done`, `cancelled` | `open` |
| Task priority | `1` (critical), `2` (high), `3` (normal), `4` (low) | `3` |
| Project status | `active`, `on_hold`, `completed`, `archived` | `active` |
| Meeting status | `scheduled`, `completed`, `cancelled` | `scheduled` |
| Decision status | `proposed`, `accepted`, `rejected`, `superseded` | `proposed` |
| Decision impact | `low`, `medium`, `high` | `medium` |
| Process type | `workflow`, `checklist` | `checklist` |
| Process status | `draft`, `active`, `archived` | `draft` |
| Process run status | `not_started`, `in_progress`, `completed`, `cancelled` | `not_started` |
| Run step status | `pending`, `in_progress`, `completed`, `skipped` | `pending` |
| Task generation | `all_at_once`, `step_by_step` | `all_at_once` |
| Release status | `planned`, `in_progress`, `released`, `cancelled` | `planned` |
| Area status | `active` (default) | `active` |
| Content format | `rich` (default) | `rich` |
---
# Part 5: Frontend
## 19. Template Architecture
All templates live in `/opt/lifeos/dev/templates/` and extend `base.html`.
### base.html Shell Structure
```
<html data-theme="dark">
<head>
<link rel="stylesheet" href="/static/style.css">
<link href="fonts.googleapis.com/css2?DM+Sans+JetBrains+Mono">
</head>
<body>
<div class="app-layout">
<aside class="sidebar">
<div class="sidebar-header">LifeOS logo</div>
<nav class="sidebar-nav">
[14 quick-access links with SVG icons]
[Domain tree: domain > area > project hierarchy]
[Admin section: History, Trash, Manage, Theme toggle]
</nav>
</aside>
<main class="main-content">
<header class="topbar">
[DEV badge if development]
[Timer pill (hidden by default)]
[Quick capture form]
[Search trigger button]
</header>
<div class="page-content">
{% block content %}{% endblock %}
</div>
</main>
</div>
[Search modal overlay]
[Mobile bottom nav bar]
[Mobile more panel overlay]
<script src="/static/app.js"></script>
</body>
</html>
```
### Template Inventory (51 files)
**Core:** base.html, dashboard.html
**List views (18):** tasks, notes, projects, contacts, areas, domains, links, lists, meetings, decisions, weblinks, appointments, time_entries, files, processes, process_runs, capture, time_budgets
**Detail views (9):** task_detail, project_detail, note_detail, contact_detail, meeting_detail, appointment_detail, decision_detail, process_run_detail, list_detail
**Forms (15):** task_form, note_form, project_form, contact_form, area_form, domain_form, link_form, list_form, meeting_form, decision_form, weblink_form, weblink_folder_form, appointment_form, processes_form, time_budgets_form
**Specialized (7):** focus, calendar, eisenhower, search, trash, history, capture_convert
**File management (2):** file_preview, file_upload
---
## 20. CSS Design System
### Design Tokens (Custom Properties)
**Dark Theme (`[data-theme="dark"]`):**
```css
--bg: #0D0E13; --surface: #14161F; --surface2: #1A1D28;
--surface3: #222533; --border: #252836; --border-light: #2E3244;
--text: #DDE1F5; --text-secondary: #9CA3C4; --muted: #5A6080;
--accent: #4F6EF7; --accent-hover: #6380FF; --accent-soft: rgba(79,110,247,.12);
--green: #22C98A; --green-soft: rgba(34,201,138,.12);
--amber: #F5A623; --amber-soft: rgba(245,166,35,.12);
--red: #F05252; --red-soft: rgba(240,82,82,.12);
--purple: #9B7FF5; --purple-soft: rgba(155,127,245,.12);
--shadow: 0 2px 8px rgba(0,0,0,.3);
--shadow-lg: 0 8px 32px rgba(0,0,0,.4);
```
**Light Theme (`[data-theme="light"]`):**
```css
--bg: #F0F2F8; --surface: #FFFFFF; --surface2: #F7F8FC;
--text: #171926; --muted: #8892B0;
/* Same accent/semantic colors with adjusted soft variants */
```
**Root Variables (theme-independent):**
```css
--font-body: 'DM Sans', -apple-system, sans-serif;
--font-mono: 'JetBrains Mono', monospace;
--radius: 8px; --radius-sm: 4px; --radius-lg: 12px;
--sidebar-width: 260px; --topbar-height: 52px;
--transition: 150ms ease;
```
### Color Usage by Context
| Context | Color |
|---------|-------|
| Priority 1 (Critical) | `--red` |
| Priority 2 (High) | `--amber` |
| Priority 3 (Normal) | `--accent` |
| Priority 4 (Low) | `--muted` |
| Status: open | `--accent` |
| Status: in_progress | `--amber` |
| Status: blocked | `--red` |
| Status: done/completed | `--green` |
| Status: cancelled/archived | `--muted` |
| Status: on_hold | `--purple` |
| Calendar: appointments | `--amber` |
| Calendar: meetings | `--purple` |
| Calendar: tasks | `--accent` |
### Responsive Breakpoint
**Single breakpoint: `max-width: 768px`**
Mobile overrides:
- Sidebar: `display: none`
- Main content: `margin-left: 0`
- Form grid: 1 column
- Dashboard grid: 1 column
- Page content: `padding: 16px`
- Mobile bottom nav: `display: flex` (56px fixed bottom)
- Body: `padding-bottom: 60px`
---
## 21. JavaScript Interactions (app.js, ~190 lines)
All JS runs on `DOMContentLoaded`. No framework. No build step.
### Theme Toggle
- Reads/saves theme to `localStorage['lifeos-theme']`
- Sets `data-theme` attribute on `<html>`
- Toggle function called from sidebar button
### Sidebar Domain Collapse
- Click `.domain-header` → toggle `.collapsed` on `.domain-children`
- State persisted per domain in `localStorage['sidebar-{domainId}']`
- Restored on page load
### Filter Auto-Submit
- Any `<select data-auto-submit>` → on `change`, submits parent `<form>`
### Delete Confirmation
- Any `<form data-confirm="message">` → on `submit`, shows `confirm()` dialog
### Search Modal
- **Open:** Cmd/Ctrl+K or click search trigger
- **Close:** Escape key or click backdrop
- **Live search:** 200ms debounce, fetches `/search/api?q={query}&limit=8`
- **Navigation:** Arrow keys move `.active` class through results, Enter navigates
- **Results:** Grouped by entity type, shows name + context + status badge
- **HTML escaping:** `escHtml()` helper for safe rendering
### Timer Pill
- **Poll:** `/time/running` every 30 seconds
- **Display:** If running, shows task name + elapsed time (h:mm:ss)
- **Update:** 1-second interval updates elapsed display client-side
- **State:** `timerStartAt` (Date), `timerInterval` (setInterval ID)
### Mobile More Panel
- `#mobMoreBtn` click → toggle `.open` on `#mobMore` + `#mobOverlay`
- Overlay click → close panel
---
## 22. Page Patterns
### List View Pattern
```
Page Header (title + count + "New" button)
└── Quick Add form (optional, for tasks)
└── Filters bar (select dropdowns with auto-submit)
└── Card
└── List rows (checkbox + priority dot + title + tags + meta + status + actions)
└── Empty state (if no items)
```
### Form View Pattern
```
Breadcrumb (entity list > "New"/"Edit")
└── Page Header
└── Card
└── Form (grid: 2 columns on desktop, 1 on mobile)
└── Form groups (label + input/select/textarea)
└── Full-width fields (description, tags)
└── Form actions (Submit + Cancel)
```
### Detail View Pattern
```
Breadcrumb (domain > project > item)
└── Detail Header (title + action buttons)
└── Detail Meta (status + priority + domain + project + dates)
└── Description card (if exists)
└── Tags row (if exists)
└── Tab Strip (overview, notes, weblinks, files, lists, etc.)
└── Tab Content (varies by tab)
└── Footer metadata (created/completed timestamps)
```
---
## 23. Responsive & Mobile
### Desktop Layout
- Fixed sidebar (260px) + flexible main content
- Sticky topbar (52px)
- Two-column form grids
- Hover states reveal action buttons on list rows
### Mobile Layout (< 768px)
- Sidebar hidden
- Bottom nav bar (56px) with 5 items: Home, Focus, Tasks, Capture, More
- "More" button opens slide-up panel with remaining navigation
- Forms collapse to single column
- Quick capture input expands to full width
- Font-size: 16px on inputs to prevent iOS zoom
- Touch targets: min 44px height for buttons
---
# Part 6: Testing
## 24. Test Architecture
The test suite uses **dynamic introspection** - it discovers routes from the live FastAPI app at runtime. No hardcoded routes.
### Test Files
| File | Lines | Purpose |
|------|-------|---------|
| `conftest.py` | 361 | Fixtures, seed data, client setup |
| `registry.py` | 67 | Route discovery, classification, seed mapping |
| `introspect.py` | 357 | Route introspection engine |
| `form_factory.py` | 204 | Automatic form data generation |
| `test_smoke_dynamic.py` | 76 | Auto-parametrized GET tests |
| `test_crud_dynamic.py` | 168 | Auto-parametrized POST tests |
| `test_business_logic.py` | 2,635 | Hand-written behavioral tests |
| `route_report.py` | 65 | CLI route dump utility |
| `run_tests.sh` | 23 | Test runner script |
### Introspection Engine (introspect.py)
Walks the live FastAPI app and extracts metadata for every route:
- Path, methods, endpoint name
- Route kind classification (LIST, DETAIL, CREATE, EDIT, DELETE, TOGGLE, ACTION, etc.)
- Form fields with names, types, required/optional status, defaults
- Query parameters
- File upload detection
### Route Registry (registry.py)
Classifies routes into buckets for parametrized tests:
- `GET_NO_PARAMS`: All GET routes without path parameters
- `GET_WITH_PARAMS`: All GET routes with `{id}` placeholders
- `POST_CREATE`: Routes ending in `/create`
- `POST_EDIT`: Routes ending in `/{id}/edit`
- `POST_DELETE`: Routes ending in `/{id}/delete`
- `POST_ACTION`: All other POST routes
Maps route prefixes to seed data via `PREFIX_TO_SEED`.
### Form Factory (form_factory.py)
Generates valid POST form data automatically from introspected Form fields using multi-level heuristics:
1. FK fields → seed UUIDs
2. Date/time fields → generated dynamically
3. Field name patterns → 40+ hardcoded values
4. Type-based fallback
5. Required string fallback
### Seed Data (conftest.py)
Session-scoped fixture inserts 19 fixed-UUID entities:
- Core: domain, area, project, task
- CRM: contact, meeting, decision, appointment
- Content: note, list, link, weblink, weblink_folder
- System: capture, focus, process, process_step, process_run, time_budget, file
Fixed UUIDs (e.g., `a0000000-0000-0000-0000-000000000001`) ensure stable references.
---
## 25. Test Suite Reference
### Test Types
**Smoke Tests** (`test_smoke_dynamic.py`):
- Verify all GET endpoints return 200
- Test detail routes with valid and invalid UUIDs
- Auto-parametrized from introspected routes
**CRUD Tests** (`test_crud_dynamic.py`):
- Verify POST create/edit/delete routes redirect (303)
- Auto-generate form data via form_factory
- Test order: create → edit → action → delete
**Business Logic Tests** (`test_business_logic.py`):
- 10+ test classes, ~50 tests total
- Timer constraints, task filtering, status transitions
- Capture conversions, meeting relationships
- Admin trash lifecycle, soft delete behavior
- Search behavior, sidebar integrity, focus workflow
### Running Tests
```bash
docker exec lifeos-dev bash /app/tests/run_tests.sh # Full suite
docker exec lifeos-dev bash /app/tests/run_tests.sh smoke # GET endpoints
docker exec lifeos-dev bash /app/tests/run_tests.sh crud # POST endpoints
docker exec lifeos-dev bash /app/tests/run_tests.sh logic # Business logic
docker exec lifeos-dev bash /app/tests/run_tests.sh report # Route dump
docker exec lifeos-dev bash /app/tests/run_tests.sh fast # Smoke, stop first fail
docker exec lifeos-dev bash /app/tests/run_tests.sh -k "timer" # Keyword filter
```
### Resetting Test DB After Schema Changes
```bash
docker exec lifeos-db psql -U postgres -c "DROP DATABASE IF EXISTS lifeos_test;"
docker exec lifeos-db psql -U postgres -c "CREATE DATABASE lifeos_test;"
docker exec lifeos-db pg_dump -U postgres -d lifeos_dev --schema-only -f /tmp/s.sql
docker exec lifeos-db psql -U postgres -d lifeos_test -f /tmp/s.sql -q
```
---
# Part 7: Operations
## 26. Deployment & Hot Reload
The dev container uses a volume mount (`-v /opt/lifeos/dev:/app`) plus uvicorn `--reload`. This means:
1. Edit any file in `/opt/lifeos/dev/` on the host
2. Uvicorn detects the change and restarts automatically
3. No container rebuild needed
4. No deploy script needed
**Verification after changes:**
```bash
docker logs lifeos-dev --tail 10 # Check for import/syntax errors
curl -s -o /dev/null -w "%{http_code}" http://localhost:8003/ # Should be 200
```
---
## 27. Backup & Restore
### Backup
```bash
mkdir -p /opt/lifeos/backups
docker exec lifeos-db pg_dump -U postgres -d lifeos_dev -Fc -f /tmp/lifeos_dev_backup.dump
docker cp lifeos-db:/tmp/lifeos_dev_backup.dump /opt/lifeos/backups/lifeos_dev_$(date +%Y%m%d_%H%M%S).dump
```
### Restore
```bash
docker exec lifeos-db psql -U postgres -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = 'lifeos_dev' AND pid <> pg_backend_pid();"
docker exec lifeos-db psql -U postgres -c "DROP DATABASE lifeos_dev;"
docker exec lifeos-db psql -U postgres -c "CREATE DATABASE lifeos_dev;"
docker cp /opt/lifeos/backups/FILENAME.dump lifeos-db:/tmp/restore.dump
docker exec lifeos-db pg_restore -U postgres -d lifeos_dev /tmp/restore.dump
docker restart lifeos-dev
```
---
## 28. Build Roadmap
### Current State (as of 2026-03-02)
- **Phase 1, Tier 3 complete.** All 6 Tier 3 features built.
- 23 routers, 51 templates, 48 database tables
- 121+ discovered routes
### What's Built
Core CRUD for: domains, areas, projects, tasks, notes, links, focus, capture, contacts, search, admin/trash, lists, files, meetings, decisions, weblinks, appointments, time_tracking, processes, calendar, time_budgets, eisenhower, history.
Working features: collapsible sidebar nav tree, task filtering/sorting/priority/status/context, daily focus, markdown notes, file uploads with preview, global search (Cmd/K) via tsvector, admin trash with restore, time tracking with topbar timer pill, timer play/stop on task rows and detail pages, unified calendar view, Eisenhower matrix, time budgets with actual vs budgeted, process templates with runs, change history feed.
### Remaining Roadmap
**Tier 4:**
- Releases/milestones CRUD
- Task dependencies (DAG with cycle detection)
- Task templates
- Note wiki-linking
- Note folders
- Bulk actions
- CSV export
- Drag-to-reorder
- Reminders
- Dashboard metrics
**Phase 2:**
- DAG visualization
- MCP/AI gateway (dual: MCP + OpenAI function calling)
- Note version history
**Phase 3:**
- Mobile improvements
- Authentication
- Browser extension
---
## Known Traps & Gotchas
1. **`time_entries` has no `updated_at` column.** BaseRepository.soft_delete() will fail. Use direct SQL for delete/restore on this table.
2. **Junction tables use raw SQL, not BaseRepository.** They have composite PKs and no `id` column.
3. **Timer constraint:** Only one timer at a time. `time_entries WHERE end_at IS NULL` detects running timer.
4. **Nullable fields in BaseRepository:** If a form field should allow NULL, add it to the `nullable_fields` set. Otherwise `update()` silently skips null values.
5. **Schema .sql files may be stale.** Always verify against live DB with `\d table_name`.
6. **No pagination.** All list views load all rows. Fine at current data volume.
7. **No CSRF protection.** Single-user system.
8. **SSL cert path** uses `kasm.invixiom.com-0001` (not `kasm.invixiom.com`).
9. **Test suite not fully green.** Async event loop fixes and seed data corrections are in progress.
10. **asyncpg requires native Python types.** ISO date strings must be converted to `date`/`datetime` objects before passing to queries. BaseRepository's `_coerce_value()` handles this automatically.
---
*Document generated 2026-03-02. This is the single source of truth for rebuilding the Life OS system from ground zero.*