-- ============================================================================ -- 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.';