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

77 KiB

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

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):

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:

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.comlocalhost:8003 (dev)
  • lifeos.invixiom.comlocalhost: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):

engine = create_async_engine(
    DATABASE_URL,
    echo=os.getenv("ENVIRONMENT") == "development",
    pool_size=5,
    max_overflow=10,
    pool_pre_ping=True,
)

Session management:

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

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

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").

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").

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.

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.

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.

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.

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.

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.

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);

Quick-access bookmarks organized by domain/area/project.

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.

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.

Similar to links but with task/meeting association and folder organization.

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.

Hierarchical folder system for organizing weblinks.

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.

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.

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.

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.

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.

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).

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.

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.

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.

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.

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.

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.

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.

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).

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.

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).

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.

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.

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.

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.

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.

-- 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:

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

# 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

@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):

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:

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:

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:

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: NoneIS 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:

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.

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:

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: opendone (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"]):

--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"]):

--bg: #F0F2F8;           --surface: #FFFFFF;       --surface2: #F7F8FC;
--text: #171926;         --muted: #8892B0;
/* Same accent/semantic colors with adjusted soft variants */

Root Variables (theme-independent):

--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

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

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:

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

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

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.