Files
oikos/migrations/022_knowledge_revisions.up.sql
dtoro ce0e4142ff feat(api): add knowledge base write path, revision history, and drift tooling
The Knowledge page was read-only from the HTTP API — the only writer was
the agent's MCP upsert_knowledge tool. Adds create/update/soft-delete/
restore/trash endpoints, a DB-trigger-backed revision history (catches
both the web UI and the MCP tool), and maintenance endpoints: duplicate
detection (pg_trgm + complete-linkage clustering), tag rename/normalize,
orphan detection, and merge.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 22:50:29 +02:00

120 lines
5.8 KiB
PL/PgSQL

-- 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 — <date>" 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;