Files
krow_backend/migrations/000010_definition_versions.up.sql
2026-08-28 12:21:44 +05:30

120 lines
5.8 KiB
PL/PgSQL

-- ============================================================================
-- Krow — immutable published versions
--
-- Phase 3. §3: "Immutable versions. Editing publishes a new version. Running
-- conversations pin the version they started with."
--
-- WHAT WAS WRONG
--
-- `agent_definitions` holds one row per (org, definition_id) and editing it
-- UPDATEs that row in place. The `version` column moves, but nothing keeps what
-- version 2 said — so "which agent answered this?" has no answer once somebody
-- saves, and the `agent_version` recorded on every run in `agent_runs` points at
-- a definition that no longer exists in that form.
--
-- That is tolerable for a curated set shipped with the deployment and wrong the
-- moment a tenant edits their own agent, which is exactly the line Phase 3 has
-- to cross.
--
-- WHY A SECOND TABLE RATHER THAN VERSIONING THE FIRST
--
-- The alternative is to widen the unique index to (org_id, definition_id,
-- version) and mark one row current. It is fewer tables and it makes every
-- existing read ambiguous: a query for "the Activity Agent" would silently
-- return however many rows exist, and the ones that forgot the version
-- predicate would appear to work until the second version was published.
--
-- So `agent_definitions` keeps meaning exactly what it means today — the
-- current, editable definition — and every read of it is unchanged. This table
-- is the history beside it, and it is APPEND-ONLY: no UPDATE path exists in the
-- repository, and the trigger below refuses one at the database.
--
-- WHAT PINS A RUN
--
-- A run records agent_version already. With this table that number becomes
-- resolvable: the loader can load the definition AS IT WAS, which is what makes
-- a resumed run — and, more importantly, an approved write — execute against
-- the agent the person was actually looking at. A confirmation approved against
-- version 3 must not be carried out by version 4's tool list.
--
-- WHAT IS DELIBERATELY ABSENT
--
-- a diff or patch format Versions are whole snapshots. A patch chain has to
-- be replayed to be read, and a corrupted link makes
-- every later version unreadable. Markdown is small.
-- deletion There is no path to remove a version. A trajectory
-- referencing one that had been deleted would be a
-- record nobody can explain, which is the thing
-- §6 exists to prevent.
-- ============================================================================
SET search_path = public;
CREATE TABLE definition_versions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
-- Which kind of definition. Agents and skills version identically and are
-- kept in one table for that reason: two tables with the same columns and the
-- same rules is two places to fix the next rule.
kind text NOT NULL,
org_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE,
-- The author-facing id, NOT a foreign key to agent_definitions. A version
-- must outlive the definition it came from: deleting an agent must not
-- destroy the record of what it said while it was answering.
definition_id text NOT NULL,
version integer NOT NULL,
-- The whole definition as it was. Snapshot, not patch — see the note above.
markdown text NOT NULL,
-- Denormalised for listing a history without parsing every blob.
name text NOT NULL DEFAULT '',
description text NOT NULL DEFAULT '',
pages text[] NOT NULL DEFAULT '{}',
-- Who published it and when. SET NULL so a departed author's versions stay
-- readable — the organization still has to answer for what its agents did.
published_by uuid REFERENCES users (id) ON DELETE SET NULL,
published_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT definition_versions_kind_check CHECK (kind IN ('agent', 'skill')),
CONSTRAINT definition_versions_version_positive CHECK (version >= 1),
CONSTRAINT definition_versions_markdown_not_blank CHECK (length(btrim(markdown)) > 0),
-- One row per version per definition per tenant. This is what makes a version
-- number mean something: publishing the same number twice is a bug, and it
-- fails here rather than leaving two rows that disagree.
CONSTRAINT definition_versions_unique UNIQUE (org_id, kind, definition_id, version)
);
-- "Show me this definition's history", newest first. Also the lookup that
-- resolves one specific version, which is the hot path.
CREATE INDEX definition_versions_lookup_idx
ON definition_versions (org_id, kind, definition_id, version DESC);
-- Append-only, enforced here rather than trusted to the repository.
--
-- A published version is a record of what a person approved and what an agent
-- answered with. Code that edits one is code that rewrites history, and the
-- reason to put this in the database is that the repository is not the only
-- thing that can reach the table — a migration, a console session and a future
-- service all can.
CREATE FUNCTION definition_versions_immutable() RETURNS trigger AS $$
BEGIN
RAISE EXCEPTION
'definition_versions is append-only: version % of % cannot be % (publish a new version instead)',
OLD.version, OLD.definition_id, lower(TG_OP);
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER definition_versions_no_update
BEFORE UPDATE OR DELETE ON definition_versions
FOR EACH ROW EXECUTE FUNCTION definition_versions_immutable();
COMMENT ON TABLE definition_versions IS
'Every published version of an agent or skill, as a whole snapshot. Append-only, enforced by '
'trigger. A run records agent_version; this is what makes that number resolvable back to the '
'definition that actually answered. See §3.';