-- ============================================================================ -- ctxd — Context Daemon Schema -- PostgreSQL 16 -- ============================================================================ -- ============================================================================ -- USERS -- ============================================================================ CREATE TABLE users ( user_id TEXT PRIMARY KEY, display_name TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'contributor' CHECK (role IN ('admin', 'contributor', 'service')), token_hash TEXT, active BOOLEAN NOT NULL DEFAULT TRUE, created_at TEXT NOT NULL DEFAULT to_char(now() at time zone 'utc', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'), updated_at TEXT NOT NULL DEFAULT to_char(now() at time zone 'utc', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') ); CREATE UNIQUE INDEX idx_users_lower ON users (LOWER(user_id)); -- ============================================================================ -- PROJECTS -- ============================================================================ CREATE TABLE projects ( project_id TEXT PRIMARY KEY, display_name TEXT NOT NULL, description TEXT, metadata_tags TEXT DEFAULT '[]', shared_version INTEGER NOT NULL DEFAULT 0, auto_sync BOOLEAN NOT NULL DEFAULT FALSE, sync_path TEXT, created_at TEXT NOT NULL DEFAULT to_char(now() at time zone 'utc', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'), updated_at TEXT NOT NULL DEFAULT to_char(now() at time zone 'utc', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') ); -- ============================================================================ -- PROJECT PERMISSIONS (admin overrides all) -- ============================================================================ CREATE TABLE project_permissions ( id SERIAL PRIMARY KEY, project_id TEXT NOT NULL REFERENCES projects(project_id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, permission TEXT NOT NULL DEFAULT 'editor' CHECK (permission IN ('owner', 'editor', 'viewer')), UNIQUE(project_id, user_id) ); -- ============================================================================ -- USER PROFILES (personal context — timezone, preferences, style) -- ============================================================================ CREATE TABLE user_profiles ( user_id TEXT PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE, content TEXT NOT NULL DEFAULT '', version INTEGER NOT NULL DEFAULT 1, updated_at TEXT NOT NULL DEFAULT to_char(now() at time zone 'utc', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') ); -- ============================================================================ -- PROJECT CONTEXT — THE AUTHORITATIVE SHARED COPY -- This is the compiled markdown: context.md + decisions/ + runbooks/ -- that gets served to agents. -- ============================================================================ CREATE TABLE project_context ( project_id TEXT PRIMARY KEY REFERENCES projects(project_id) ON DELETE CASCADE, content TEXT NOT NULL DEFAULT '', version INTEGER NOT NULL DEFAULT 0, updated_by TEXT REFERENCES users(user_id) ON DELETE SET NULL, updated_at TEXT NOT NULL DEFAULT to_char(now() at time zone 'utc', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') ); -- ============================================================================ -- CONTEXT FILES — individual files within a project (decisions/, runbooks/) -- The daemon compiles these into project_context.content on demand. -- version tracks this file's edit count (independent of the shared version). -- ============================================================================ CREATE TABLE context_files ( file_id SERIAL PRIMARY KEY, project_id TEXT NOT NULL REFERENCES projects(project_id) ON DELETE CASCADE, file_path TEXT NOT NULL, content TEXT NOT NULL DEFAULT '', version INTEGER NOT NULL DEFAULT 1, updated_by TEXT REFERENCES users(user_id) ON DELETE SET NULL, updated_at TEXT NOT NULL DEFAULT to_char(now() at time zone 'utc', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'), UNIQUE(project_id, file_path) ); -- ============================================================================ -- USER WORKSPACES — per-user forks of a project -- When a user edits, they work on their own copy. base_version tracks which -- shared version they started from. current_version tracks their edits. -- ============================================================================ CREATE TABLE user_workspaces ( workspace_id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, project_id TEXT NOT NULL REFERENCES projects(project_id) ON DELETE CASCADE, status TEXT NOT NULL DEFAULT 'in_progress' CHECK (status IN ('in_progress', 'submitted', 'merged', 'abandoned')), base_version INTEGER NOT NULL, current_version INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL DEFAULT to_char(now() at time zone 'utc', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'), updated_at TEXT NOT NULL DEFAULT to_char(now() at time zone 'utc', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'), UNIQUE(user_id, project_id, status) ); -- ============================================================================ -- WORKSPACE FILES — per-user fork of context_files -- Mirrors the same file_path as context_files but in the user's workspace. -- ============================================================================ CREATE TABLE workspace_files ( file_id SERIAL PRIMARY KEY, workspace_id TEXT NOT NULL REFERENCES user_workspaces(workspace_id) ON DELETE CASCADE, file_path TEXT NOT NULL, content TEXT NOT NULL DEFAULT '', version INTEGER NOT NULL DEFAULT 1, updated_at TEXT NOT NULL DEFAULT to_char(now() at time zone 'utc', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'), UNIQUE(workspace_id, file_path) ); -- ============================================================================ -- CHANGE REQUESTS — submit / review / merge workflow -- ============================================================================ CREATE TABLE change_requests ( request_id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL REFERENCES user_workspaces(workspace_id) ON DELETE CASCADE, project_id TEXT NOT NULL REFERENCES projects(project_id), submitted_by TEXT REFERENCES users(user_id) ON DELETE SET NULL, status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected', 'merged')), diff_summary TEXT, target_version INTEGER NOT NULL, base_version INTEGER NOT NULL, created_at TEXT NOT NULL DEFAULT to_char(now() at time zone 'utc', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'), updated_at TEXT NOT NULL DEFAULT to_char(now() at time zone 'utc', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') ); -- ============================================================================ -- REVIEWS — approvals/rejections on change requests -- ============================================================================ CREATE TABLE reviews ( review_id SERIAL PRIMARY KEY, request_id TEXT NOT NULL REFERENCES change_requests(request_id) ON DELETE CASCADE, reviewer_id TEXT REFERENCES users(user_id) ON DELETE SET NULL, decision TEXT NOT NULL CHECK (decision IN ('approved', 'rejected')), comments TEXT, created_at TEXT NOT NULL DEFAULT to_char(now() at time zone 'utc', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'), UNIQUE(request_id, reviewer_id) ); -- ============================================================================ -- SNAPSHOTS — point-in-time copies of project or workspace content -- Stored as files on disk at the path in storage_path. -- ============================================================================ CREATE TABLE snapshots ( snapshot_id TEXT PRIMARY KEY, project_id TEXT NOT NULL REFERENCES projects(project_id) ON DELETE CASCADE, user_id TEXT REFERENCES users(user_id) ON DELETE CASCADE, workspace_id TEXT REFERENCES user_workspaces(workspace_id) ON DELETE SET NULL, version_from INTEGER, version_to INTEGER, storage_path TEXT NOT NULL, content_hash TEXT NOT NULL, size_bytes INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT to_char(now() at time zone 'utc', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') ); CREATE INDEX idx_snapshots_cleanup ON snapshots (project_id, user_id, created_at); -- ============================================================================ -- AUDIT LOG — append-only (INSERT only, never UPDATE or DELETE) -- ============================================================================ CREATE TABLE audit_log ( entry_id SERIAL PRIMARY KEY, user_id TEXT REFERENCES users(user_id) ON DELETE SET NULL, agent_id TEXT NOT NULL DEFAULT 'cli', session_id TEXT, project_id TEXT REFERENCES projects(project_id) ON DELETE SET NULL, operation TEXT NOT NULL CHECK (operation IN ( 'read', 'update', 'create', 'delete', 'submit', 'approve', 'reject', 'merge', 'sync', 'search', 'export', 'restore', 'login', 'logout', 'import' )), entity_type TEXT, entity_id TEXT, summary TEXT NOT NULL, details_json TEXT, created_at TEXT NOT NULL DEFAULT to_char(now() at time zone 'utc', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') ); CREATE INDEX idx_audit_user ON audit_log (user_id, created_at); CREATE INDEX idx_audit_project ON audit_log (project_id, created_at); CREATE INDEX idx_audit_agent ON audit_log (agent_id, created_at); CREATE INDEX idx_audit_op ON audit_log (operation, created_at); -- Note: audit_log append-only enforcement is handled at the application layer. -- DB-level BEFORE UPDATE/DELETE triggers conflict with FK ON DELETE SET NULL -- cascades from projects, which internally issue UPDATE statements. -- ============================================================================ -- FULL-TEXT SEARCH (tsvector with GIN index) -- Separate FTS table with triggers to keep index in sync with source tables. -- ============================================================================ CREATE TABLE fts_context ( id SERIAL PRIMARY KEY, source_id TEXT NOT NULL, content TEXT NOT NULL, project_id TEXT NOT NULL, file_path TEXT NOT NULL, source_type TEXT NOT NULL, tsv tsvector NOT NULL, UNIQUE(source_type, source_id) ); CREATE INDEX idx_fts_context_tsv ON fts_context USING GIN (tsv); CREATE INDEX idx_fts_context_project ON fts_context (project_id); CREATE INDEX idx_fts_context_source ON fts_context (source_type, source_id); -- ── Trigger functions for project_context ─────────────────────────────────── CREATE OR REPLACE FUNCTION fts_pc_insert() RETURNS TRIGGER AS $$ BEGIN INSERT INTO fts_context (source_id, content, project_id, file_path, source_type, tsv) VALUES (NEW.project_id, NEW.content, NEW.project_id, 'context.md', 'project_context', to_tsvector('english', NEW.content)); RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER tr_fts_pc_insert AFTER INSERT ON project_context FOR EACH ROW EXECUTE FUNCTION fts_pc_insert(); CREATE OR REPLACE FUNCTION fts_pc_update() RETURNS TRIGGER AS $$ BEGIN DELETE FROM fts_context WHERE source_type = 'project_context' AND source_id = OLD.project_id; INSERT INTO fts_context (source_id, content, project_id, file_path, source_type, tsv) VALUES (NEW.project_id, NEW.content, NEW.project_id, 'context.md', 'project_context', to_tsvector('english', NEW.content)); RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER tr_fts_pc_update AFTER UPDATE ON project_context FOR EACH ROW EXECUTE FUNCTION fts_pc_update(); CREATE OR REPLACE FUNCTION fts_pc_delete() RETURNS TRIGGER AS $$ BEGIN DELETE FROM fts_context WHERE source_type = 'project_context' AND source_id = OLD.project_id; RETURN OLD; END; $$ LANGUAGE plpgsql; CREATE TRIGGER tr_fts_pc_delete AFTER DELETE ON project_context FOR EACH ROW EXECUTE FUNCTION fts_pc_delete(); -- ── Trigger functions for context_files ───────────────────────────────────── CREATE OR REPLACE FUNCTION fts_cf_insert() RETURNS TRIGGER AS $$ BEGIN INSERT INTO fts_context (source_id, content, project_id, file_path, source_type, tsv) VALUES (NEW.file_id::text, NEW.content, NEW.project_id, NEW.file_path, 'context_file', to_tsvector('english', NEW.content)); RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER tr_fts_cf_insert AFTER INSERT ON context_files FOR EACH ROW EXECUTE FUNCTION fts_cf_insert(); CREATE OR REPLACE FUNCTION fts_cf_update() RETURNS TRIGGER AS $$ BEGIN DELETE FROM fts_context WHERE source_type = 'context_file' AND source_id = OLD.file_id::text; INSERT INTO fts_context (source_id, content, project_id, file_path, source_type, tsv) VALUES (NEW.file_id::text, NEW.content, NEW.project_id, NEW.file_path, 'context_file', to_tsvector('english', NEW.content)); RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER tr_fts_cf_update AFTER UPDATE ON context_files FOR EACH ROW EXECUTE FUNCTION fts_cf_update(); CREATE OR REPLACE FUNCTION fts_cf_delete() RETURNS TRIGGER AS $$ BEGIN DELETE FROM fts_context WHERE source_type = 'context_file' AND source_id = OLD.file_id::text; RETURN OLD; END; $$ LANGUAGE plpgsql; CREATE TRIGGER tr_fts_cf_delete AFTER DELETE ON context_files FOR EACH ROW EXECUTE FUNCTION fts_cf_delete(); -- ── Trigger functions for user_profiles ───────────────────────────────────── CREATE OR REPLACE FUNCTION fts_up_insert() RETURNS TRIGGER AS $$ BEGIN INSERT INTO fts_context (source_id, content, project_id, file_path, source_type, tsv) VALUES (NEW.user_id, NEW.content, '~user', NEW.user_id, 'user_profile', to_tsvector('english', NEW.content)); RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER tr_fts_up_insert AFTER INSERT ON user_profiles FOR EACH ROW EXECUTE FUNCTION fts_up_insert(); CREATE OR REPLACE FUNCTION fts_up_update() RETURNS TRIGGER AS $$ BEGIN DELETE FROM fts_context WHERE source_type = 'user_profile' AND source_id = OLD.user_id; INSERT INTO fts_context (source_id, content, project_id, file_path, source_type, tsv) VALUES (NEW.user_id, NEW.content, '~user', NEW.user_id, 'user_profile', to_tsvector('english', NEW.content)); RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER tr_fts_up_update AFTER UPDATE ON user_profiles FOR EACH ROW EXECUTE FUNCTION fts_up_update(); CREATE OR REPLACE FUNCTION fts_up_delete() RETURNS TRIGGER AS $$ BEGIN DELETE FROM fts_context WHERE source_type = 'user_profile' AND source_id = OLD.user_id; RETURN OLD; END; $$ LANGUAGE plpgsql; CREATE TRIGGER tr_fts_up_delete AFTER DELETE ON user_profiles FOR EACH ROW EXECUTE FUNCTION fts_up_delete(); -- ============================================================================ -- SEED DATA (for development / first-run) -- ============================================================================ INSERT INTO users (user_id, display_name, role) VALUES ('admin', 'Administrator', 'admin'), ('hermes-gateway', 'Hermes Agent', 'service') ON CONFLICT DO NOTHING; INSERT INTO projects (project_id, display_name, description) VALUES ('welcome', 'Welcome', 'Getting started guide and documentation for ctxd'), ('remote-rig', 'RemoteRig', 'Multi-camera remote monitoring system') ON CONFLICT DO NOTHING; -- Project context is seeded by the Python init code (cmd_init) -- to ensure real newlines, not literal backslash-n from SQL strings.