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

119 lines
5.7 KiB
SQL

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