Files
krow_backend/migrations/000005_agent_skill_definitions.up.sql
2026-08-24 13:06:29 +05:30

294 lines
14 KiB
SQL

-- ============================================================================
-- Krow — authored agent and skill definitions
--
-- Phase 4C. Two tables, and deliberately only two.
--
-- WHAT THIS IS FOR
--
-- An agent and a skill are each a Markdown file with YAML frontmatter. Three
-- tiers of them exist, and only two live here:
--
-- shipped src/agents/**/*.md, src/skills/**/*.md — product source,
-- versioned in Git, bundled at build time. NO ROWS HERE. They
-- are code: putting them in a table would trade `git log`,
-- code review and atomic deploy for nothing, and would make
-- every shipped-definition change a data migration.
-- organization authored in the app, shared across one tenant.
-- personal authored in the app, private to one user.
--
-- Before this migration the last two lived in `user_preferences.extra`, a
-- jsonb blob with no owner, no tenancy, no size bound, no server-side
-- validation and no query surface — and returned in full by GET /api/v1/me on
-- every page load. This migration is that move.
--
-- WHY TWO TABLES AND NOT ONE
--
-- Agents and skills do not share a lifecycle, and the difference is not
-- incidental:
--
-- agents status draft | published | archived, plus an integer version that
-- only goes up. They are published artefacts.
-- skills status active | inactive, and NO version at all — the frontend has
-- no notion of a skill version and none is invented here.
--
-- One table would need a union CHECK permitting `version 5, status inactive`,
-- and a version column that is forever 1 for half the rows. Two tables cost a
-- little repetition and buy a schema where every row is meaningful.
--
-- WHAT IS DELIBERATELY ABSENT
--
-- definition_versions nothing retains prior Markdown; no rollback
-- feature exists to serve.
-- definition_permissions the `permissions:` frontmatter block stays inside
-- the Markdown, parsed and unenforced, until its
-- semantics are defined (Phase 4H).
-- agent_skills `skills:` names ids in a namespace that includes
-- agent_subagents SHIPPED definitions, which have no rows here. A
-- join table would need foreign keys to rows that do
-- not exist. Resolution stays in the registry.
-- agent_knowledge embedded in frontmatter; no corpus exists.
-- conversations deferred.
--
-- `disabledSkills` and `removedSkills` also stay where they are, in
-- user_preferences.extra. They are per-account arrays of skill *ids* — mostly
-- shipped ids — so they are suppression preferences over a namespace, not
-- definitions, and they are already in the right place.
--
-- Target schema: public. No system schema is read or written.
-- ============================================================================
-- Atomicity comes from golang-migrate: the postgres driver sends this file as a
-- single simple query, which Postgres executes inside one implicit transaction.
-- Any failure below rolls the whole migration back.
SET search_path = public;
-- ── agent_definitions ───────────────────────────────────────────────────────
CREATE TABLE agent_definitions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
-- The author-facing id — `id:` in the frontmatter, the address that skills,
-- subagents and the shadow-by-id merge all refer to. NOT globally unique:
-- the whole point of shadowing is that a personal definition may carry the
-- same id as an organization one, which may carry the same id as a shipped
-- one. See the two partial unique indexes below for what IS unique.
definition_id text NOT NULL,
-- Tenancy. NOT NULL on a personal definition too: a user belongs to exactly
-- one organization, so a personal definition is always inside a tenant, and
-- carrying org_id means the organization predicate applies to every read
-- whether or not the ownership predicate does. Defence in depth for one
-- column.
org_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE,
visibility text NOT NULL,
-- Ownership, in two columns because the two tiers have genuinely different
-- deletion semantics and one column cannot carry both:
--
-- owner_user_id set ONLY for a personal definition. CASCADE: a personal
-- definition dies with its owner, because there is nobody
-- else it could belong to.
-- created_by always the author. SET NULL: an organization-shared
-- definition must survive its author leaving the company.
-- Nullable for exactly that reason, and because that is
-- already this schema's pattern — job_postings.created_by.
owner_user_id uuid REFERENCES users (id) ON DELETE CASCADE,
created_by uuid REFERENCES users (id) ON DELETE SET NULL,
-- The definition, verbatim. THIS IS THE AUTHORITATIVE ARTEFACT: a definition
-- must survive a round trip to a .md file on disk unchanged, so the Markdown
-- is the record and the columns below are derived from it.
markdown text NOT NULL,
-- ── Projections ──────────────────────────────────────────────────────────
-- Everything below is parsed OUT of `markdown` by the server, never accepted
-- from a request body, and rebuildable by re-parsing every row. They exist so
-- that "this organization's published agents" is a query rather than a parse
-- of every blob, and so version conflicts can be detected with a predicate
-- rather than a read-modify-write in the browser.
status text NOT NULL DEFAULT 'draft',
-- Monotonic. A first publish keeps its version; republishing moves it on, so
-- "what is live" is always a specific number.
version integer NOT NULL DEFAULT 1,
name text NOT NULL DEFAULT '',
description text NOT NULL DEFAULT '',
-- text[] rather than jsonb, matching courses.training_outline: this is a
-- plain list of strings and every reader treats it as one.
pages text[] NOT NULL DEFAULT '{}',
created_date timestamptz NOT NULL DEFAULT now(),
updated_date timestamptz NOT NULL DEFAULT now(),
-- The format the frontend validator already enforces, restated here so the
-- database refuses what the application would have refused. Lower-case
-- letters, digits and dashes, not starting with a dash.
CONSTRAINT agent_definitions_definition_id_format
CHECK (definition_id ~ '^[a-z0-9][a-z0-9-]*$'),
CONSTRAINT agent_definitions_visibility_check
CHECK (visibility IN ('personal', 'organization')),
-- The ownership invariant, stated once and in both directions: a personal
-- definition HAS an owner, an organization definition has NONE. Written as an
-- equality of two booleans rather than two OR'd implications, because that is
-- the whole rule in one line and cannot be half-satisfied.
CONSTRAINT agent_definitions_visibility_owner
CHECK ((visibility = 'personal') = (owner_user_id IS NOT NULL)),
-- text + CHECK rather than a PostgreSQL enum, following users.role and
-- users.status. A status vocabulary that may grow is easier to widen with an
-- ALTER of a constraint than with ALTER TYPE ... ADD VALUE, and it keeps the
-- down migration to a table drop with no type left behind.
CONSTRAINT agent_definitions_status_check
CHECK (status IN ('draft', 'published', 'archived')),
CONSTRAINT agent_definitions_version_check
CHECK (version >= 1),
-- A bound on the blob, which is the other half of moving definitions out of
-- user_preferences.extra. The largest definition shipped with the product is
-- 3,156 bytes and the median is 1,354, so 65,536 is roughly twenty times the
-- biggest thing anyone has actually written — unreachable by legitimate
-- authoring, and low enough that no single row can be used to bloat a
-- response. An empty definition cannot parse, so zero length is refused too.
--
-- `length()` counts CHARACTERS, which is the semantic this schema already
-- uses (organizations_name_not_blank, users_email_not_blank). A worst-case
-- 4-byte-per-character document would therefore be up to 256 KiB on disk;
-- that is accepted deliberately in exchange for one consistent rule.
CONSTRAINT agent_definitions_markdown_size
CHECK (length(markdown) BETWEEN 1 AND 65536)
);
-- Uniqueness, per tier. Partial rather than whole-table because the two tiers
-- are keyed on different columns: a personal definition is unique to its owner,
-- an organization definition to its tenant. Partial also keeps each index to
-- only the rows it governs.
--
-- (A plain UNIQUE (owner_user_id, definition_id) would technically also work,
-- because NULLs are distinct by default and organization rows all have a NULL
-- owner — but it would be relying on a subtlety to express a rule, which is
-- how the rule gets misread later.)
CREATE UNIQUE INDEX agent_definitions_personal_key
ON agent_definitions (owner_user_id, definition_id)
WHERE visibility = 'personal';
CREATE UNIQUE INDEX agent_definitions_org_key
ON agent_definitions (org_id, definition_id)
WHERE visibility = 'organization';
-- Listing one organization's definitions, split by tier. Also covers the
-- org_id foreign key, which PostgreSQL does not index on its own.
CREATE INDEX agent_definitions_org_visibility_idx
ON agent_definitions (org_id, visibility);
-- Listing one user's own definitions, and the owner_user_id foreign key's
-- cascade check.
CREATE INDEX agent_definitions_owner_idx
ON agent_definitions (owner_user_id);
-- The runtime's own query: the agents that are actually live in a tenant. An
-- unpublished agent contributes nothing at runtime, so the index carries only
-- published rows — the same shape as job_postings_org_active_idx.
CREATE INDEX agent_definitions_published_idx
ON agent_definitions (org_id, visibility)
WHERE status = 'published';
-- There is deliberately NO index on created_by. It is attribution only: no
-- listing is keyed by it, and its ON DELETE SET NULL scan happens when a user
-- is deleted, which is rare and against a small table. An index would cost a
-- write on every definition change to serve nothing.
COMMENT ON TABLE agent_definitions IS
'Agent definitions authored in the application. Shipped agents live in Git '
'under src/agents/ and have no rows here.';
COMMENT ON COLUMN agent_definitions.definition_id IS
'Author-facing id from the frontmatter. Unique per owner or per organization, '
'never globally: shadow-by-id is the point.';
COMMENT ON COLUMN agent_definitions.markdown IS
'The definition verbatim, and the authoritative record. Every other column '
'except the identity and ownership ones is parsed out of this.';
COMMENT ON COLUMN agent_definitions.owner_user_id IS
'Set only when visibility = personal. Organization definitions have none.';
COMMENT ON COLUMN agent_definitions.created_by IS
'The author, for attribution. Nullable so a shared definition survives its '
'author being deleted.';
-- ── skill_definitions ───────────────────────────────────────────────────────
--
-- The same shape, minus `version`. Skills have no version and no publish step
-- in the product: a skill is active or inactive, and that is the whole of its
-- lifecycle. Adding a version column "for symmetry" would be inventing a
-- concept the frontend does not have and cannot set.
CREATE TABLE skill_definitions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
definition_id text NOT NULL,
org_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE,
visibility text NOT NULL,
owner_user_id uuid REFERENCES users (id) ON DELETE CASCADE,
created_by uuid REFERENCES users (id) ON DELETE SET NULL,
markdown text NOT NULL,
-- Projections, as above.
status text NOT NULL DEFAULT 'active',
name text NOT NULL DEFAULT '',
description text NOT NULL DEFAULT '',
pages text[] NOT NULL DEFAULT '{}',
created_date timestamptz NOT NULL DEFAULT now(),
updated_date timestamptz NOT NULL DEFAULT now(),
CONSTRAINT skill_definitions_definition_id_format
CHECK (definition_id ~ '^[a-z0-9][a-z0-9-]*$'),
CONSTRAINT skill_definitions_visibility_check
CHECK (visibility IN ('personal', 'organization')),
CONSTRAINT skill_definitions_visibility_owner
CHECK ((visibility = 'personal') = (owner_user_id IS NOT NULL)),
CONSTRAINT skill_definitions_status_check
CHECK (status IN ('active', 'inactive')),
CONSTRAINT skill_definitions_markdown_size
CHECK (length(markdown) BETWEEN 1 AND 65536)
);
CREATE UNIQUE INDEX skill_definitions_personal_key
ON skill_definitions (owner_user_id, definition_id)
WHERE visibility = 'personal';
CREATE UNIQUE INDEX skill_definitions_org_key
ON skill_definitions (org_id, definition_id)
WHERE visibility = 'organization';
CREATE INDEX skill_definitions_org_visibility_idx
ON skill_definitions (org_id, visibility);
CREATE INDEX skill_definitions_owner_idx
ON skill_definitions (owner_user_id);
-- The runtime loads active skills; an inactive one is registered and switched
-- off. Partial for the same reason as the agent index above.
CREATE INDEX skill_definitions_active_idx
ON skill_definitions (org_id, visibility)
WHERE status = 'active';
COMMENT ON TABLE skill_definitions IS
'Skill definitions authored in the application. Shipped skills live in Git '
'under src/skills/ and have no rows here. Skills have no version: their '
'lifecycle is active or inactive.';
COMMENT ON COLUMN skill_definitions.markdown IS
'The definition verbatim, and the authoritative record.';