-- 022_knowledge_revisions.up.sql -- Version history for knowledge_entities, so an edit can never be silently lost. -- -- The concrete hazard this closes: the MCP tool `upsert_knowledge` -- (internal/mcp/server.go) keys on title and does -- `ON CONFLICT (entity_id) DO UPDATE SET content = EXCLUDED.content` — -- unconditionally. Before this migration, an operator hand-editing a note in -- the web UI would have that edit overwritten with no trace the next time -- Nomos re-upserted a note with the same title. There was no history table -- and no way to recover the prior body. -- -- The snapshot is a BEFORE UPDATE **trigger** rather than application-level -- code in the HTTP handler, specifically because there are two independent -- writers: the web API (new in this change) and the MCP tool the agent uses. -- App-level snapshotting would only cover whichever path remembered to call -- it. A trigger covers both, plus any future writer and any manual psql fix. -- -- Each row in knowledge_revisions is a *superseded* version: the state of the -- note before the update that displaced it. The current version always lives -- in knowledge_entities, never here, so "history" is -- knowledge_entities + knowledge_revisions ordered by version_at DESC. -- Who authored the version currently in knowledge_entities. Distinct from -- `source`, which is overloaded: it holds either 'nomos-agent' (written via -- MCP) or a seed file path ('containers/101-jellyfin') and is NOT updated on -- conflict, so a seeded doc later rewritten by the agent still reports its -- original file path. edited_by answers the question the UI actually asks — -- "did a human or the agent last touch this?" — without disturbing source, -- which the seeding logic still relies on. ALTER TABLE knowledge_entities ADD COLUMN IF NOT EXISTS edited_by TEXT NOT NULL DEFAULT ''; -- Backfill: every existing row's last writer is whatever source says. For -- agent-written notes that's exactly right; for seeded notes it records the -- seed path, which is the honest answer (no human has edited them yet). UPDATE knowledge_entities SET edited_by = COALESCE(source, '') WHERE edited_by = ''; -- Soft delete. A hard DELETE would cascade knowledge_revisions away with the -- entity, which contradicts the point of this migration — removing a note is -- exactly the moment its history matters most. Deleting sets deleted_at; all -- read paths filter it out, the revision trail survives, and an accidental -- delete is recoverable by clearing the column. ALTER TABLE knowledge_entities ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; -- Partial index: every list/search/read query carries `deleted_at IS NULL`, -- and deleted notes are expected to stay a small minority. CREATE INDEX IF NOT EXISTS idx_knowledge_live ON knowledge_entities (updated_at DESC) WHERE deleted_at IS NULL; CREATE TABLE IF NOT EXISTS knowledge_revisions ( id BIGSERIAL PRIMARY KEY, entity_id UUID NOT NULL REFERENCES entities(id) ON DELETE CASCADE, title TEXT NOT NULL, content TEXT NOT NULL, source TEXT, tags TEXT[], edited_by TEXT NOT NULL DEFAULT '', -- When this version was written (the superseded row's updated_at). version_at TIMESTAMPTZ NOT NULL, -- When it was replaced. version_at of revision N and revised_at of -- revision N-1 bracket how long that version was the live one. revised_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -- The only access pattern: "show me the history of this note, newest first." CREATE INDEX IF NOT EXISTS idx_knowledge_revisions_entity ON knowledge_revisions (entity_id, version_at DESC); -- Snapshot the outgoing row whenever the substance changes. Deliberately -- ignores updated_at-only touches: upsert_knowledge sets `updated_at = now()` -- on every call even when re-writing byte-identical content (it has no -- change detection), and without this guard a re-run of the same agent task -- would pile up identical revisions and bury the real edits. -- -- `search` is a GENERATED column and is intentionally not carried into -- revisions — it is derived from title+content and would be dead weight. CREATE OR REPLACE FUNCTION snapshot_knowledge_revision() RETURNS TRIGGER AS $$ BEGIN IF OLD.title IS DISTINCT FROM NEW.title OR OLD.content IS DISTINCT FROM NEW.content OR OLD.tags IS DISTINCT FROM NEW.tags THEN INSERT INTO knowledge_revisions (entity_id, title, content, source, tags, edited_by, version_at) VALUES (OLD.entity_id, OLD.title, OLD.content, OLD.source, OLD.tags, OLD.edited_by, OLD.updated_at); END IF; RETURN NEW; END; $$ LANGUAGE plpgsql; -- DROP + CREATE rather than CREATE OR REPLACE: Postgres 16 has no -- CREATE OR REPLACE TRIGGER for this form, and the migration must stay -- re-runnable. DROP TRIGGER IF EXISTS trg_knowledge_revision ON knowledge_entities; CREATE TRIGGER trg_knowledge_revision BEFORE UPDATE ON knowledge_entities FOR EACH ROW EXECUTE FUNCTION snapshot_knowledge_revision(); -- Trigram similarity, for the duplicate-detection view. The knowledge base -- has already accumulated near-duplicates that exact matching cannot catch — -- four separate "rclone backup live inspection — " investigations, each -- a fresh note where an update to the existing one was meant. upsert_knowledge -- keys on exact title, so a date suffix is enough to fork a new note. -- -- similarity() over titles is what lets the UI cluster those and offer a -- merge. fuzzystrmatch (levenshtein) was the alternative; trigram wins here -- because these titles differ by whole appended words rather than typos, and -- because it comes with a GIN index while levenshtein cannot be indexed. CREATE EXTENSION IF NOT EXISTS pg_trgm; CREATE INDEX IF NOT EXISTS idx_knowledge_title_trgm ON knowledge_entities USING gin (title gin_trgm_ops) WHERE deleted_at IS NULL;