agent build
This commit is contained in:
19
migrations/000006_agent_runs.down.sql
Normal file
19
migrations/000006_agent_runs.down.sql
Normal file
@@ -0,0 +1,19 @@
|
||||
-- Reverses 000006.
|
||||
--
|
||||
-- Drops exactly the one table it created. Its indexes and constraints go with
|
||||
-- it, and the self-reference on parent_run_id needs no special handling
|
||||
-- because the whole table leaves at once.
|
||||
--
|
||||
-- No enum type was created — termination is text with a CHECK — so nothing is
|
||||
-- left behind.
|
||||
--
|
||||
-- Rolling this back destroys every recorded trajectory. That is the correct
|
||||
-- meaning of reversing the migration that introduced them, and it is worth
|
||||
-- stating plainly: it discards the evidence for every agent run this
|
||||
-- deployment has served, including the failed ones. It reaches nothing else —
|
||||
-- agent and skill definitions are 000005's and are untouched by both
|
||||
-- directions of this migration.
|
||||
|
||||
SET search_path = public;
|
||||
|
||||
DROP TABLE IF EXISTS public.agent_runs;
|
||||
149
migrations/000006_agent_runs.up.sql
Normal file
149
migrations/000006_agent_runs.up.sql
Normal file
@@ -0,0 +1,149 @@
|
||||
-- ============================================================================
|
||||
-- Krow — agent run trajectories
|
||||
--
|
||||
-- Phase 1. One table, and deliberately one.
|
||||
--
|
||||
-- WHAT THIS IS FOR
|
||||
--
|
||||
-- Every agent run records what happened: each message, each tool call, each
|
||||
-- tool result, and the budget as it stood before each dispatch. §6 of the
|
||||
-- platform contract is explicit that this is not optional telemetry — it is
|
||||
-- what makes debugging and evals possible at all. A run whose trajectory was
|
||||
-- dropped is a run nobody can explain afterwards, and an eval suite with no
|
||||
-- trajectory to assert against cannot check `tools_called` or `must_not_leak`.
|
||||
--
|
||||
-- WHY ONE TABLE AND NOT TWO
|
||||
--
|
||||
-- The obvious alternative is `agent_runs` plus `agent_run_entries`, one row per
|
||||
-- entry. It is rejected because entries are never queried independently of
|
||||
-- their run: nothing asks "show me every tool call across all runs" without
|
||||
-- also wanting the run it belonged to. A child table would buy relational
|
||||
-- tidiness and cost a join on the one access pattern that exists — fetch one
|
||||
-- run whole — plus an insert per entry instead of one insert per run.
|
||||
--
|
||||
-- I3 is what makes this safe. A run is bounded by a step cap, a tool-call cap,
|
||||
-- a token budget and a wall-clock deadline, so `entries` cannot grow without
|
||||
-- limit the way an unbounded conversation log could. The jsonb column is
|
||||
-- bounded by construction rather than by hope.
|
||||
--
|
||||
-- WHAT IS DELIBERATELY ABSENT
|
||||
--
|
||||
-- agent_run_entries see above.
|
||||
-- conversations a run is one turn. Threading runs into a conversation
|
||||
-- is a surface-layer concern and no surface asks for it
|
||||
-- yet; adding the column later is trivial, and inventing
|
||||
-- the semantics now is not.
|
||||
-- confirmations the ConfirmationPending termination exists in the
|
||||
-- vocabulary, but no write tool does, so there is
|
||||
-- nothing yet to store a pending confirmation FOR.
|
||||
-- Phase 2.
|
||||
-- cost in currency token counts are the durable fact; a price is a
|
||||
-- contract term that changes underneath stored rows.
|
||||
-- Derived at read time, never written here.
|
||||
--
|
||||
-- RETENTION
|
||||
--
|
||||
-- No policy is imposed. Trajectories carry message text, so they are subject to
|
||||
-- whatever retention the deployment owes its tenants — that is a decision for
|
||||
-- an operator, not a default baked into a migration. The index on started_at
|
||||
-- exists so a deletion sweep can be written efficiently when that decision is
|
||||
-- made.
|
||||
-- ============================================================================
|
||||
|
||||
SET search_path = public;
|
||||
|
||||
CREATE TABLE agent_runs (
|
||||
-- The run id the executor generated and returned to the caller. Text, not
|
||||
-- uuid: it is opaque, it appears in support conversations, and its format is
|
||||
-- the runtime's business rather than the schema's.
|
||||
run_id text PRIMARY KEY,
|
||||
|
||||
-- Delegation. A subagent's run links to its parent, and §6 requires the two
|
||||
-- to be separate trajectories rather than one merged log. SET NULL rather
|
||||
-- than CASCADE: deleting a parent run must not silently destroy the record
|
||||
-- of what its subagents did.
|
||||
parent_run_id text REFERENCES agent_runs (run_id) ON DELETE SET NULL,
|
||||
|
||||
-- Tenancy, on the same terms as every other table here: NOT NULL, so the
|
||||
-- organization predicate applies to every read without a call site having to
|
||||
-- remember it. I5.
|
||||
org_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE,
|
||||
|
||||
-- Who the run executed on behalf of. SET NULL so a departed user's runs stay
|
||||
-- auditable — the org still needs to answer for what its agents did.
|
||||
user_id uuid REFERENCES users (id) ON DELETE SET NULL,
|
||||
|
||||
-- The agent as it was AT RUN TIME, by author-facing id and version, not by a
|
||||
-- foreign key. A trajectory must stay readable after its definition is
|
||||
-- edited, archived or deleted, and a FK would either block that deletion or
|
||||
-- cascade away the evidence.
|
||||
agent_id text NOT NULL,
|
||||
agent_version integer NOT NULL DEFAULT 1,
|
||||
|
||||
-- What was asked for, and what actually answered. Both, because the mapping
|
||||
-- is a deployment decision that changes: reading a trajectory a year later
|
||||
-- must not require knowing what "balanced" was routed to that week.
|
||||
tier text NOT NULL,
|
||||
model text NOT NULL DEFAULT '',
|
||||
|
||||
started_at timestamptz NOT NULL,
|
||||
ended_at timestamptz NOT NULL,
|
||||
|
||||
-- Exactly one of six. A CHECK rather than an enum type, matching 000005's
|
||||
-- treatment of the status vocabularies — a new termination reason should be
|
||||
-- a migration, but not one that requires ALTER TYPE.
|
||||
termination text NOT NULL,
|
||||
|
||||
-- The record itself: messages, tool calls, tool results and budget
|
||||
-- snapshots, in sequence.
|
||||
entries jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
|
||||
-- Token accounting, as columns rather than inside the jsonb, because these
|
||||
-- are the fields anything aggregates over — per-tenant spend, per-agent cost,
|
||||
-- budget tuning — and none of that should require unpacking a document.
|
||||
input_tokens bigint NOT NULL DEFAULT 0,
|
||||
output_tokens bigint NOT NULL DEFAULT 0,
|
||||
cached_tokens bigint NOT NULL DEFAULT 0,
|
||||
total_tokens bigint NOT NULL DEFAULT 0,
|
||||
model_calls integer NOT NULL DEFAULT 0,
|
||||
|
||||
created_date timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT agent_runs_termination_check CHECK (termination IN (
|
||||
'Completed', 'BudgetExceeded', 'Deadline',
|
||||
'ConfirmationPending', 'ToolFailure', 'Refused'
|
||||
)),
|
||||
CONSTRAINT agent_runs_entries_is_array CHECK (jsonb_typeof(entries) = 'array'),
|
||||
CONSTRAINT agent_runs_ended_after_started CHECK (ended_at >= started_at),
|
||||
CONSTRAINT agent_runs_tokens_non_negative CHECK (
|
||||
input_tokens >= 0 AND output_tokens >= 0 AND cached_tokens >= 0 AND total_tokens >= 0
|
||||
),
|
||||
-- A run cannot be its own parent. Deeper cycles are prevented by the depth
|
||||
-- cap in the runtime; this catches the one case a single row can express.
|
||||
CONSTRAINT agent_runs_no_self_parent CHECK (parent_run_id IS DISTINCT FROM run_id)
|
||||
);
|
||||
|
||||
-- The list view: one tenant's runs, most recent first. Covers the retention
|
||||
-- sweep too.
|
||||
CREATE INDEX agent_runs_org_started_idx
|
||||
ON agent_runs (org_id, started_at DESC);
|
||||
|
||||
-- "Show me this agent's recent runs" — the Insights panel's actual question,
|
||||
-- and the one an eval report groups by.
|
||||
CREATE INDEX agent_runs_org_agent_started_idx
|
||||
ON agent_runs (org_id, agent_id, started_at DESC);
|
||||
|
||||
-- "Which runs failed, and how" — partial, because Completed is the common case
|
||||
-- and indexing it would double the write cost to serve a query nobody makes.
|
||||
CREATE INDEX agent_runs_failures_idx
|
||||
ON agent_runs (org_id, termination, started_at DESC)
|
||||
WHERE termination <> 'Completed';
|
||||
|
||||
-- A parent's delegated runs.
|
||||
CREATE INDEX agent_runs_parent_idx
|
||||
ON agent_runs (parent_run_id)
|
||||
WHERE parent_run_id IS NOT NULL;
|
||||
|
||||
COMMENT ON TABLE agent_runs IS
|
||||
'One row per agent run: the full trajectory, its termination reason and its token cost. '
|
||||
'Written once at the end of a run. See §6 — this is what makes debugging and evals possible.';
|
||||
16
migrations/000007_agent_confirmations.down.sql
Normal file
16
migrations/000007_agent_confirmations.down.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
-- Reverses 000007.
|
||||
--
|
||||
-- Drops the one table it created, with its two partial indexes.
|
||||
--
|
||||
-- Rolling this back discards every outstanding confirmation. Any approval a
|
||||
-- person has been asked for and not yet given becomes unanswerable — the token
|
||||
-- they hold will resolve against nothing, and the agent will describe the write
|
||||
-- again the next time it is asked. That is the correct behaviour for a lost
|
||||
-- confirmation store: fail closed, ask again. Nothing that was already written
|
||||
-- is affected, because a spent confirmation has, by then, already done its job.
|
||||
--
|
||||
-- No enum type was created, so nothing is left behind.
|
||||
|
||||
SET search_path = public;
|
||||
|
||||
DROP TABLE IF EXISTS public.agent_confirmations;
|
||||
118
migrations/000007_agent_confirmations.up.sql
Normal file
118
migrations/000007_agent_confirmations.up.sql
Normal file
@@ -0,0 +1,118 @@
|
||||
-- ============================================================================
|
||||
-- Krow — pending write confirmations
|
||||
--
|
||||
-- Phase 2. The other half of I4.
|
||||
--
|
||||
-- 000006 left a note saying confirmations were deliberately absent because
|
||||
-- "the ConfirmationPending termination exists in the vocabulary, but no write
|
||||
-- tool does". A write tool does now — assign_worker — so this is that table.
|
||||
--
|
||||
-- WHAT A ROW IS
|
||||
--
|
||||
-- A question a person has been asked and has not yet answered: "shall this
|
||||
-- agent assign Maya Chen to Friday's bar shift?". It exists between the moment
|
||||
-- an agent proposed a write and the moment somebody approved or ignored it.
|
||||
--
|
||||
-- WHY A TOKEN IS NOT A BOOLEAN
|
||||
--
|
||||
-- The naive implementation of "writes need confirmation" is a yes/no flag. It
|
||||
-- has a hole: a person approves one write, and the flag then authorises a
|
||||
-- different one. So the columns below are a FINGERPRINT of the exact call that
|
||||
-- was described — tool, arguments, caller, tenant, run — and the runtime
|
||||
-- validates an incoming token against all of them. Approving "assign Maya to
|
||||
-- Friday" cannot become "assign Dan to Saturday", because that is a different
|
||||
-- inputs_hash and the token simply does not match it.
|
||||
--
|
||||
-- The arguments themselves are hashed rather than stored. The rendered
|
||||
-- description in `payload` is what a person read and is worth keeping; the raw
|
||||
-- arguments are not independently useful, and hashing them means this table
|
||||
-- never becomes a second copy of whatever the agent was about to write.
|
||||
--
|
||||
-- SINGLE USE
|
||||
--
|
||||
-- consumed_at is what makes one approval buy one write. The runtime claims a
|
||||
-- token with a conditional UPDATE (`WHERE consumed_at IS NULL`), so two
|
||||
-- concurrent attempts to spend the same token cannot both win — the loser sees
|
||||
-- zero rows updated and is refused. A boolean checked and then written in two
|
||||
-- statements would race, and the race is a duplicated write.
|
||||
--
|
||||
-- Consumed on ANY attempt, valid or not. A token presented against the wrong
|
||||
-- call was either replayed or guessed; neither deserves a second try.
|
||||
--
|
||||
-- EXPIRY
|
||||
--
|
||||
-- An approval is a judgement about a moment. A yes clicked on a two-day-old
|
||||
-- "assign Maya to Friday's shift" answers a question whose facts have moved,
|
||||
-- and the person clicking has no way to know that. Expired rows are refused on
|
||||
-- read regardless of the sweep, so the sweep is housekeeping rather than a
|
||||
-- security control.
|
||||
--
|
||||
-- WHAT IS DELIBERATELY ABSENT
|
||||
--
|
||||
-- approved_by the surface has no approval UI yet, so there is no
|
||||
-- honest value to write. Added with the UI, not before:
|
||||
-- a column that is always NULL is worse than no column,
|
||||
-- because it looks like an audit trail.
|
||||
-- rejection a declined confirmation is simply never spent and
|
||||
-- then expires. Recording a "no" is a product decision
|
||||
-- (does the agent get told? does it retry?) and §12
|
||||
-- says not to resolve those unilaterally.
|
||||
-- ============================================================================
|
||||
|
||||
SET search_path = public;
|
||||
|
||||
CREATE TABLE agent_confirmations (
|
||||
-- The token itself, as issued. Opaque and unguessable — 24 random bytes.
|
||||
-- Primary key because looking one up IS the operation.
|
||||
token text PRIMARY KEY,
|
||||
|
||||
-- Tenancy. I5: a confirmation belongs to an organization and is resolvable
|
||||
-- only by a caller inside it.
|
||||
org_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE,
|
||||
|
||||
-- Who was asked. SET NULL so a departed user's pending writes stay auditable
|
||||
-- rather than vanishing along with the reason a row exists.
|
||||
user_id uuid REFERENCES users (id) ON DELETE SET NULL,
|
||||
|
||||
-- The run that proposed it. Text and no foreign key, matching agent_runs:
|
||||
-- the trajectory is written when a run ENDS, so at the moment a confirmation
|
||||
-- is issued the run it belongs to does not exist yet. A FK here would make
|
||||
-- the natural ordering illegal.
|
||||
run_id text NOT NULL,
|
||||
|
||||
-- The fingerprint. All four, together, are what a token authorises.
|
||||
tool text NOT NULL,
|
||||
inputs_hash text NOT NULL,
|
||||
|
||||
-- What the person actually read. Kept because "what were they told they were
|
||||
-- approving" is the question an audit of an agent-initiated write asks, and
|
||||
-- re-rendering it later from the arguments would answer a subtly different
|
||||
-- one — the world moves, and the description would move with it.
|
||||
payload jsonb NOT NULL,
|
||||
|
||||
created_date timestamptz NOT NULL DEFAULT now(),
|
||||
expires_at timestamptz NOT NULL,
|
||||
|
||||
-- NULL until spent. Set by the conditional UPDATE that claims the token.
|
||||
consumed_at timestamptz,
|
||||
|
||||
CONSTRAINT agent_confirmations_expires_after_created CHECK (expires_at > created_date),
|
||||
CONSTRAINT agent_confirmations_payload_is_object CHECK (jsonb_typeof(payload) = 'object'),
|
||||
CONSTRAINT agent_confirmations_token_not_blank CHECK (length(btrim(token)) > 0)
|
||||
);
|
||||
|
||||
-- "What is this person waiting to approve" — the approval queue's query, and
|
||||
-- the only listing this table serves. Partial: a spent or expired row is not
|
||||
-- part of anyone's queue, and indexing it would grow the index without bound
|
||||
-- as history accumulates.
|
||||
CREATE INDEX agent_confirmations_pending_idx
|
||||
ON agent_confirmations (org_id, created_date DESC)
|
||||
WHERE consumed_at IS NULL;
|
||||
|
||||
-- The retention sweep, and nothing else.
|
||||
CREATE INDEX agent_confirmations_expires_idx
|
||||
ON agent_confirmations (expires_at);
|
||||
|
||||
COMMENT ON TABLE agent_confirmations IS
|
||||
'One row per write an agent proposed and a person has not yet approved. The token is bound '
|
||||
'to a fingerprint of the exact call described, and is single-use. See I4 and tools/confirm.go.';
|
||||
21
migrations/000008_knowledge.down.sql
Normal file
21
migrations/000008_knowledge.down.sql
Normal file
@@ -0,0 +1,21 @@
|
||||
-- Reverses 000008.
|
||||
--
|
||||
-- Drops the two tables and the similarity function. Chunks go with their
|
||||
-- documents by CASCADE, and both tables' indexes go with them.
|
||||
--
|
||||
-- Rolling this back destroys the index, not the sources. Documents were
|
||||
-- ingested FROM somewhere — a policy store, an upload, a file — and this table
|
||||
-- is a derived copy, so the cost of reversing is a re-ingest and a re-embed
|
||||
-- rather than lost content. The re-embed is the expensive half, and it is worth
|
||||
-- saying out loud before anyone runs this in an environment where embedding
|
||||
-- costs money.
|
||||
--
|
||||
-- Order matters: the function is dropped last because nothing depends on it,
|
||||
-- and the tables are dropped before it only so that a partially-applied
|
||||
-- rollback leaves no table referencing a missing function.
|
||||
|
||||
SET search_path = public;
|
||||
|
||||
DROP TABLE IF EXISTS public.knowledge_chunks;
|
||||
DROP TABLE IF EXISTS public.knowledge_documents;
|
||||
DROP FUNCTION IF EXISTS public.knowledge_dot(real[], real[]);
|
||||
227
migrations/000008_knowledge.up.sql
Normal file
227
migrations/000008_knowledge.up.sql
Normal file
@@ -0,0 +1,227 @@
|
||||
-- ============================================================================
|
||||
-- Krow — the knowledge layer
|
||||
--
|
||||
-- Phase 2. Documents an agent may retrieve from, chunked and permissioned.
|
||||
--
|
||||
-- THE ONE RULE THIS SCHEMA EXISTS TO ENFORCE
|
||||
--
|
||||
-- I2: ACL filtering happens BEFORE scoring, never after. Post-filtering a
|
||||
-- ranked result set leaks through counts, through ranking positions, and
|
||||
-- through summaries — "your top result was suppressed" is itself information.
|
||||
-- So permission has to be a WHERE clause that both the keyword query and the
|
||||
-- vector query can carry, which means it has to live on the chunk row and be
|
||||
-- indexable.
|
||||
--
|
||||
-- Hence `acl text[]` denormalised from the document onto every chunk. A join
|
||||
-- would work and is the tidier schema; it is rejected because a join is a thing
|
||||
-- a query can be written without, and the query that forgets it is a
|
||||
-- cross-permission read that returns plausible results and raises nothing.
|
||||
--
|
||||
-- A chunk is visible to a caller who holds ANY of its tags: `acl && $grants`.
|
||||
-- Tags are derived at ingest from the document's declared audience, never typed
|
||||
-- by a user, and the vocabulary is closed — see internal/knowledge/acl.go.
|
||||
--
|
||||
-- §5 is explicit that chunks without ACL metadata are REJECTED at ingest. The
|
||||
-- CHECK below makes that a schema property rather than a convention, because a
|
||||
-- chunk with an empty acl is not "private", it is invisible to the `&&`
|
||||
-- operator — and a document that silently indexed to nothing is a support
|
||||
-- ticket nobody can diagnose.
|
||||
--
|
||||
-- WHY EMBEDDINGS ARE real[] AND NOT vector
|
||||
--
|
||||
-- pgvector is not installed on the development machine, and installing an
|
||||
-- extension is an infrastructure decision rather than something a migration
|
||||
-- should assume. real[] with an IMMUTABLE dot-product function gives exact
|
||||
-- search with no extension and no ANN index.
|
||||
--
|
||||
-- The honest cost: this is a sequential scan over the caller's permitted chunks.
|
||||
-- That is bounded by the ACL pre-filter, which is the point — a talent caller
|
||||
-- scans their own handful of rows — but a tenant with a large shared corpus
|
||||
-- will scan all of it, and there is no index that helps. The upgrade path is
|
||||
-- pgvector: `ALTER TABLE knowledge_chunks ALTER COLUMN embedding TYPE vector(N)`
|
||||
-- plus an HNSW index, with no change to the retrieval logic because the ACL
|
||||
-- pre-filter and the RRF fusion are unaffected by how the ordering is computed.
|
||||
--
|
||||
-- Embeddings are stored UNIT-NORMALISED at ingest, so cosine similarity is a
|
||||
-- plain dot product. Normalising at query time instead would mean recomputing a
|
||||
-- magnitude per row per query, for a value that never changes.
|
||||
--
|
||||
-- WHY THE MODEL NAME IS ON THE ROW
|
||||
--
|
||||
-- Vectors from two different embedding models are not comparable — the numbers
|
||||
-- have no shared meaning — so a corpus half-migrated to a new model silently
|
||||
-- returns nonsense rather than failing. `embedding_model` lets retrieval refuse
|
||||
-- a mismatch, and lets a reindex be told apart from a fresh ingest.
|
||||
--
|
||||
-- §5: a reindex is REQUIRED whenever ACL derivation logic changes. `acl_version`
|
||||
-- records which derivation produced a row's tags, so "which documents predate
|
||||
-- the change" is answerable rather than guessed at.
|
||||
-- ============================================================================
|
||||
|
||||
SET search_path = public;
|
||||
|
||||
-- Cosine similarity for unit-normalised vectors, which is their dot product.
|
||||
--
|
||||
-- STRICT so a NULL embedding scores NULL rather than 0 — an unembedded chunk
|
||||
-- must be absent from a dense ranking, not tied for last with everything else.
|
||||
-- IMMUTABLE and PARALLEL SAFE so the planner may use it freely.
|
||||
CREATE FUNCTION knowledge_dot(a real[], b real[])
|
||||
RETURNS double precision
|
||||
LANGUAGE sql
|
||||
IMMUTABLE PARALLEL SAFE STRICT
|
||||
AS $$
|
||||
SELECT coalesce(sum(x::double precision * y::double precision), 0)
|
||||
FROM unnest(a, b) AS t(x, y)
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION knowledge_dot(real[], real[]) IS
|
||||
'Dot product of two equal-length real arrays. Equals cosine similarity when both are unit-normalised, '
|
||||
'which is how internal/knowledge stores them. Replace with pgvector''s <=> operator when the extension lands.';
|
||||
|
||||
|
||||
-- ── Documents ───────────────────────────────────────────────────────────────
|
||||
--
|
||||
-- The thing a person ingested: a policy PDF, a handbook page, a job description.
|
||||
-- Chunks belong to it, and citations point back through it.
|
||||
|
||||
CREATE TABLE knowledge_documents (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
org_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE,
|
||||
|
||||
-- Which corpus this belongs to. An agent spec names sources
|
||||
-- (`knowledge: - source: policy_docs`), and retrieval filters by them, so a
|
||||
-- source is part of the permission story rather than a label: an agent that
|
||||
-- may read policy documents does not thereby gain the shift database.
|
||||
source text NOT NULL,
|
||||
|
||||
-- The id this document has in whatever system it came from, so a re-ingest
|
||||
-- updates rather than duplicates. Unique per source per tenant.
|
||||
external_id text NOT NULL,
|
||||
|
||||
title text NOT NULL DEFAULT '',
|
||||
uri text NOT NULL DEFAULT '',
|
||||
|
||||
-- The audience, as derived grant tags. Denormalised onto every chunk below;
|
||||
-- kept here too so a re-chunk does not have to re-derive it.
|
||||
acl text[] NOT NULL,
|
||||
|
||||
-- Which ACL derivation produced those tags. §5 requires a reindex when the
|
||||
-- derivation changes, and this is what makes "reindexed or not" a fact.
|
||||
acl_version integer NOT NULL DEFAULT 1,
|
||||
|
||||
-- Free-form provenance: author, effective date, section. Read by the surface
|
||||
-- when it renders a citation. Never interpolated into a prompt as
|
||||
-- instructions — I7 applies to everything on this table.
|
||||
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
|
||||
content_hash text NOT NULL DEFAULT '',
|
||||
chunk_count integer NOT NULL DEFAULT 0,
|
||||
|
||||
ingested_at timestamptz NOT NULL DEFAULT now(),
|
||||
created_date timestamptz NOT NULL DEFAULT now(),
|
||||
updated_date timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT knowledge_documents_org_source_external_key
|
||||
UNIQUE (org_id, source, external_id),
|
||||
CONSTRAINT knowledge_documents_source_not_blank
|
||||
CHECK (length(btrim(source)) > 0),
|
||||
CONSTRAINT knowledge_documents_external_id_not_blank
|
||||
CHECK (length(btrim(external_id)) > 0),
|
||||
-- §5. A document with no audience is not private, it is unreachable.
|
||||
CONSTRAINT knowledge_documents_acl_not_empty
|
||||
CHECK (array_length(acl, 1) >= 1),
|
||||
CONSTRAINT knowledge_documents_metadata_is_object
|
||||
CHECK (jsonb_typeof(metadata) = 'object')
|
||||
);
|
||||
|
||||
CREATE INDEX knowledge_documents_org_source_idx
|
||||
ON knowledge_documents (org_id, source, ingested_at DESC);
|
||||
|
||||
-- "Which documents predate the current ACL derivation" — the reindex query.
|
||||
CREATE INDEX knowledge_documents_acl_version_idx
|
||||
ON knowledge_documents (org_id, acl_version);
|
||||
|
||||
|
||||
-- ── Chunks ──────────────────────────────────────────────────────────────────
|
||||
--
|
||||
-- What retrieval actually ranks. Everything a query needs is on this row, so
|
||||
-- the hot path never joins: tenancy, source, permission, both indexes.
|
||||
|
||||
CREATE TABLE knowledge_chunks (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
document_id uuid NOT NULL REFERENCES knowledge_documents (id) ON DELETE CASCADE,
|
||||
|
||||
-- Repeated from the document rather than joined. I5, and the same reasoning
|
||||
-- as `acl`: the predicate must be impossible to omit.
|
||||
org_id uuid NOT NULL REFERENCES organizations (id) ON DELETE CASCADE,
|
||||
source text NOT NULL,
|
||||
acl text[] NOT NULL,
|
||||
|
||||
-- Position within the document, so neighbouring chunks can be stitched back
|
||||
-- together and a citation can say where in the document it came from.
|
||||
ordinal integer NOT NULL,
|
||||
|
||||
-- The text handed to the model. Goes inside a delimited <context> block in a
|
||||
-- USER message — never the system prompt. I7: this is untrusted input, and it
|
||||
-- may well contain a sentence shaped like an instruction.
|
||||
text text NOT NULL,
|
||||
|
||||
-- A short heading trail ("Handbook › Attendance › Lateness") so a citation
|
||||
-- reads like a location rather than a uuid.
|
||||
heading text NOT NULL DEFAULT '',
|
||||
|
||||
-- The keyword half of hybrid retrieval. A stored column rather than an
|
||||
-- expression index so the same tsvector is used for ranking and for matching,
|
||||
-- and so the text configuration is fixed at write time rather than depending
|
||||
-- on whatever default_text_search_config the session happens to have.
|
||||
tsv tsvector GENERATED ALWAYS AS (
|
||||
setweight(to_tsvector('english', coalesce(heading, '')), 'A') ||
|
||||
setweight(to_tsvector('english', coalesce(text, '')), 'B')
|
||||
) STORED,
|
||||
|
||||
-- The dense half. NULL until embedded: ingest writes the row and embedding is
|
||||
-- allowed to be a separate, retryable step, because an embedding provider
|
||||
-- being down must not lose the document.
|
||||
embedding real[],
|
||||
embedding_model text NOT NULL DEFAULT '',
|
||||
|
||||
token_estimate integer NOT NULL DEFAULT 0,
|
||||
created_date timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT knowledge_chunks_document_ordinal_key UNIQUE (document_id, ordinal),
|
||||
CONSTRAINT knowledge_chunks_text_not_blank CHECK (length(btrim(text)) > 0),
|
||||
CONSTRAINT knowledge_chunks_ordinal_non_negative CHECK (ordinal >= 0),
|
||||
CONSTRAINT knowledge_chunks_acl_not_empty CHECK (array_length(acl, 1) >= 1),
|
||||
-- An embedding without a model name is a vector nobody can compare against
|
||||
-- anything. Either both or neither.
|
||||
CONSTRAINT knowledge_chunks_embedding_has_model CHECK (
|
||||
(embedding IS NULL AND embedding_model = '') OR
|
||||
(embedding IS NOT NULL AND length(btrim(embedding_model)) > 0)
|
||||
)
|
||||
);
|
||||
|
||||
-- The permission pre-filter, and the reason `acl` is an array rather than a
|
||||
-- join. GIN over the array makes `acl && $grants` an index scan, so the filter
|
||||
-- that runs BEFORE scoring is also the cheap one.
|
||||
CREATE INDEX knowledge_chunks_acl_idx ON knowledge_chunks USING gin (acl);
|
||||
|
||||
-- Tenancy and corpus, the other two halves of every WHERE clause here.
|
||||
CREATE INDEX knowledge_chunks_org_source_idx ON knowledge_chunks (org_id, source);
|
||||
|
||||
-- The keyword half.
|
||||
CREATE INDEX knowledge_chunks_tsv_idx ON knowledge_chunks USING gin (tsv);
|
||||
|
||||
-- "Which chunks still need embedding" — the backfill query, and the one that
|
||||
-- runs after an embedding model changes. Partial, because the answer is
|
||||
-- normally none and an index over every embedded chunk would earn nothing.
|
||||
CREATE INDEX knowledge_chunks_unembedded_idx
|
||||
ON knowledge_chunks (org_id, created_date)
|
||||
WHERE embedding IS NULL;
|
||||
|
||||
-- Re-chunking a document: delete its chunks, write the new ones.
|
||||
CREATE INDEX knowledge_chunks_document_idx ON knowledge_chunks (document_id, ordinal);
|
||||
|
||||
COMMENT ON TABLE knowledge_chunks IS
|
||||
'Retrievable chunks. org_id, source and acl are repeated from the document so the permission '
|
||||
'pre-filter is a WHERE clause on this table alone — I2 requires it to run before scoring, and a '
|
||||
'join is a thing a query can be written without. See internal/knowledge.';
|
||||
18
migrations/000009_confirmation_replay.down.sql
Normal file
18
migrations/000009_confirmation_replay.down.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
-- Reverses 000009.
|
||||
--
|
||||
-- Dropping `inputs` returns the system to matching a re-derived call against a
|
||||
-- hash — which is the behaviour that loses approvals when a model answers a
|
||||
-- resumed turn differently. Outstanding tokens survive the rollback and still
|
||||
-- resolve; they simply become dependent on the model repeating itself again.
|
||||
--
|
||||
-- Nothing already written is affected: a spent confirmation has, by then,
|
||||
-- already done its job.
|
||||
|
||||
SET search_path = public;
|
||||
|
||||
ALTER TABLE agent_confirmations
|
||||
DROP CONSTRAINT IF EXISTS agent_confirmations_inputs_is_object;
|
||||
|
||||
ALTER TABLE agent_confirmations
|
||||
DROP COLUMN IF EXISTS inputs,
|
||||
DROP COLUMN IF EXISTS agent_id;
|
||||
58
migrations/000009_confirmation_replay.up.sql
Normal file
58
migrations/000009_confirmation_replay.up.sql
Normal file
@@ -0,0 +1,58 @@
|
||||
-- ============================================================================
|
||||
-- Krow — store what a confirmation authorised, not just its fingerprint
|
||||
--
|
||||
-- 000007 stored a HASH of the approved call and not the call itself, with this
|
||||
-- reasoning: "The arguments themselves are hashed rather than stored... hashing
|
||||
-- them means this table never becomes a second copy of whatever the agent was
|
||||
-- about to write."
|
||||
--
|
||||
-- That reasoning was wrong twice over, and this migration is the correction.
|
||||
--
|
||||
-- WRONG ON PRIVACY
|
||||
--
|
||||
-- The arguments are already stored. `agent_runs.entries` records every tool
|
||||
-- call with its inputs verbatim, so the privacy this column was protecting had
|
||||
-- already been given away by the trajectory — and a tool call is
|
||||
-- `{"job_posting_id": "...", "worker_email": "..."}`, which is a reference to
|
||||
-- rows the caller could already read, not document content.
|
||||
--
|
||||
-- WRONG ON CORRECTNESS, WHICH IS THE REAL PROBLEM
|
||||
--
|
||||
-- With only a hash, honouring an approval means re-running the model and hoping
|
||||
-- it makes the same tool call again, so the hash matches. It frequently does
|
||||
-- not: a model is not deterministic, and on a resumed turn it may reasonably
|
||||
-- ask a clarifying question instead. Observed in practice — a person clicked
|
||||
-- Approve, the model asked which of two roles was meant, the token was never
|
||||
-- presented, and nothing happened. No error, no write, no explanation.
|
||||
--
|
||||
-- A confirmation is a person authorising a SPECIFIC ACT. The system must be
|
||||
-- able to perform that act. Storing the arguments is what makes the approval
|
||||
-- mean something rather than being a wish that the model cooperates.
|
||||
--
|
||||
-- The fingerprint stays. inputs_hash is still what a re-derived call is matched
|
||||
-- against, so the older path — model reproduces the call, hash matches — keeps
|
||||
-- working; the new column is what makes the direct path possible.
|
||||
-- ============================================================================
|
||||
|
||||
SET search_path = public;
|
||||
|
||||
ALTER TABLE agent_confirmations
|
||||
-- The arguments the approval authorises, exactly as they were fingerprinted.
|
||||
-- Nullable because rows written before this migration have no copy, and a
|
||||
-- backfill would have to invent one. Those tokens keep the old behaviour and
|
||||
-- expire within the TTL anyway.
|
||||
ADD COLUMN inputs jsonb,
|
||||
|
||||
-- Which agent proposed it. Not used to authorise — the principal and the
|
||||
-- tenant do that — but a direct replay runs a tool outside any agent's
|
||||
-- resolved tool list, and "which agent's spec offered this" is the question
|
||||
-- an audit of that will ask.
|
||||
ADD COLUMN agent_id text NOT NULL DEFAULT '';
|
||||
|
||||
ALTER TABLE agent_confirmations
|
||||
ADD CONSTRAINT agent_confirmations_inputs_is_object
|
||||
CHECK (inputs IS NULL OR jsonb_typeof(inputs) = 'object');
|
||||
|
||||
COMMENT ON COLUMN agent_confirmations.inputs IS
|
||||
'The arguments this approval authorises. Replayed directly when the token is redeemed, so '
|
||||
'honouring an approval does not depend on a model reproducing the same tool call.';
|
||||
18
migrations/000010_definition_versions.down.sql
Normal file
18
migrations/000010_definition_versions.down.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
-- Reverses 000010.
|
||||
--
|
||||
-- Drops the version history, its trigger and the trigger's function.
|
||||
--
|
||||
-- Rolling this back DISCARDS EVERY PUBLISHED VERSION. The current definition of
|
||||
-- each agent survives — that lives in agent_definitions and is untouched — but
|
||||
-- the record of what earlier versions said is gone, and every `agent_version`
|
||||
-- recorded on a past run becomes unresolvable again. Trajectories keep their
|
||||
-- number; there is simply nothing left to look it up in.
|
||||
--
|
||||
-- The trigger has to go before the table, and the function after it, because
|
||||
-- the trigger depends on the function and the table depends on the trigger.
|
||||
|
||||
SET search_path = public;
|
||||
|
||||
DROP TRIGGER IF EXISTS definition_versions_no_update ON public.definition_versions;
|
||||
DROP TABLE IF EXISTS public.definition_versions;
|
||||
DROP FUNCTION IF EXISTS public.definition_versions_immutable();
|
||||
119
migrations/000010_definition_versions.up.sql
Normal file
119
migrations/000010_definition_versions.up.sql
Normal file
@@ -0,0 +1,119 @@
|
||||
-- ============================================================================
|
||||
-- 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.';
|
||||
Reference in New Issue
Block a user