294 lines
14 KiB
SQL
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.';
|