diff --git a/deploy/postgres/goose/00044_2026-09-15-intent-plans.sql b/deploy/postgres/goose/00044_2026-09-15-intent-plans.sql new file mode 100644 index 00000000..c2d2c5f7 --- /dev/null +++ b/deploy/postgres/goose/00044_2026-09-15-intent-plans.sql @@ -0,0 +1,160 @@ +-- +goose Up +-- U3: persistent multi-intent plans. Owner is app/intentflow; other packages use the Service port. +-- JSONB is only for versioned slots / display snapshots. CAS, lease, and audit columns are typed. + +CREATE TABLE IF NOT EXISTS intent_plans ( + plan_id TEXT PRIMARY KEY, + environment TEXT NOT NULL, + project_id BIGINT NOT NULL, + session_id TEXT NOT NULL, + user_id BIGINT NOT NULL, + client_turn_id TEXT NOT NULL, + request_digest TEXT NOT NULL, + segmenter_version TEXT NOT NULL DEFAULT '', + policy_version TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL, + revision BIGINT NOT NULL DEFAULT 1, + expires_at TIMESTAMPTZ NOT NULL, + last_fencing_token BIGINT NOT NULL DEFAULT 0, + write_plan BOOLEAN NOT NULL DEFAULT FALSE, + execution_mode TEXT NOT NULL DEFAULT 'validated_only', + safe_summary TEXT NOT NULL DEFAULT '', + display_snapshot JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT chk_intent_plan_status CHECK (status IN ( + 'proposed', + 'awaiting_clarification', + 'awaiting_confirmation', + 'executing', + 'partially_completed', + 'completed', + 'failed', + 'cancelled', + 'expired', + 'superseded' + )), + CONSTRAINT chk_intent_plan_execution_mode CHECK (execution_mode IN ( + 'validated_only', + 'live' + )) +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_intent_plans_turn + ON intent_plans (environment, session_id, client_turn_id); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_intent_plans_one_active_write + ON intent_plans (environment, project_id, session_id, user_id) + WHERE write_plan AND status IN ( + 'proposed', + 'awaiting_clarification', + 'awaiting_confirmation', + 'executing', + 'partially_completed' + ); + +CREATE INDEX IF NOT EXISTS idx_intent_plans_active_scope + ON intent_plans (environment, project_id, session_id, user_id, status); + +CREATE INDEX IF NOT EXISTS idx_intent_plans_expire + ON intent_plans (expires_at) + WHERE status IN ('proposed', 'awaiting_clarification', 'awaiting_confirmation'); + +CREATE INDEX IF NOT EXISTS idx_intent_plans_retention + ON intent_plans (updated_at) + WHERE status IN ('completed', 'failed', 'cancelled', 'expired', 'superseded'); + +CREATE TABLE IF NOT EXISTS intent_plan_steps ( + step_id TEXT PRIMARY KEY, + plan_id TEXT NOT NULL REFERENCES intent_plans(plan_id) ON DELETE CASCADE, + ordinal INT NOT NULL, + action_key TEXT NOT NULL, + slots JSONB NOT NULL DEFAULT '{}'::jsonb, + depends_on TEXT[] NOT NULL DEFAULT '{}', + status TEXT NOT NULL, + expected_resource_version TEXT NOT NULL DEFAULT '', + action_digest TEXT NOT NULL DEFAULT '', + idempotency_key TEXT NOT NULL DEFAULT '', + attempt INT NOT NULL DEFAULT 0, + result_ref TEXT NOT NULL DEFAULT '', + result_digest TEXT NOT NULL DEFAULT '', + safe_summary TEXT NOT NULL DEFAULT '', + lease_owner TEXT NOT NULL DEFAULT '', + lease_until TIMESTAMPTZ, + fencing_token BIGINT NOT NULL DEFAULT 0, + version BIGINT NOT NULL DEFAULT 1, + execution_mode TEXT NOT NULL DEFAULT 'validated_only', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT chk_intent_plan_step_status CHECK (status IN ( + 'pending', + 'eligible', + 'running', + 'succeeded', + 'failed', + 'denied', + 'conflicted', + 'blocked_dependency', + 'skipped', + 'cancelled' + )), + CONSTRAINT chk_intent_plan_step_execution_mode CHECK (execution_mode IN ( + 'validated_only', + 'live' + )) +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_intent_plan_steps_ordinal + ON intent_plan_steps (plan_id, ordinal); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_intent_plan_steps_idempotency + ON intent_plan_steps (idempotency_key) + WHERE idempotency_key <> ''; + +CREATE INDEX IF NOT EXISTS idx_intent_plan_steps_claim + ON intent_plan_steps (status, lease_until); + +CREATE TABLE IF NOT EXISTS intent_plan_decisions ( + decision_id TEXT PRIMARY KEY, + plan_id TEXT NOT NULL REFERENCES intent_plans(plan_id) ON DELETE CASCADE, + nonce TEXT NOT NULL, + plan_revision BIGINT NOT NULL, + actor_user_id BIGINT NOT NULL, + kind TEXT NOT NULL, + step_ids TEXT[] NOT NULL DEFAULT '{}', + exclude_unselected BOOLEAN NOT NULL DEFAULT FALSE, + decision_digest TEXT NOT NULL, + outcome TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT chk_intent_plan_decision_kind CHECK (kind IN ( + 'confirm', + 'cancel', + 'exclude', + 'clarify', + 'edit', + 'confirm_retry', + 'continue', + 'replace' + )) +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_intent_plan_decisions_nonce + ON intent_plan_decisions (plan_id, nonce); + +CREATE INDEX IF NOT EXISTS idx_intent_plan_decisions_plan + ON intent_plan_decisions (plan_id, created_at); + +-- +goose Down +DROP INDEX IF EXISTS idx_intent_plan_decisions_plan; +DROP INDEX IF EXISTS uq_intent_plan_decisions_nonce; +DROP TABLE IF EXISTS intent_plan_decisions; +DROP INDEX IF EXISTS idx_intent_plan_steps_claim; +DROP INDEX IF EXISTS uq_intent_plan_steps_idempotency; +DROP INDEX IF EXISTS uq_intent_plan_steps_ordinal; +DROP TABLE IF EXISTS intent_plan_steps; +DROP INDEX IF EXISTS idx_intent_plans_retention; +DROP INDEX IF EXISTS idx_intent_plans_expire; +DROP INDEX IF EXISTS idx_intent_plans_active_scope; +DROP INDEX IF EXISTS uq_intent_plans_one_active_write; +DROP INDEX IF EXISTS uq_intent_plans_turn; +DROP TABLE IF EXISTS intent_plans; diff --git a/internal/db/migrate_test.go b/internal/db/migrate_test.go index e0922c81..efd02803 100644 --- a/internal/db/migrate_test.go +++ b/internal/db/migrate_test.go @@ -111,6 +111,41 @@ func TestGooseU9_00038_HasUpAndDown(t *testing.T) { } } +func TestGooseU3_00044_HasUpAndDown(t *testing.T) { + files, err := ListMigrationFiles(ResolveMigrationsDir()) + if err != nil { + t.Fatal(err) + } + var found string + for _, name := range files { + if strings.HasPrefix(name, "00044_") && strings.HasSuffix(name, ".sql") { + found = name + break + } + } + if found == "" { + t.Fatal("expected goose 00044 for intent plans") + } + raw, err := os.ReadFile(filepath.Join(ResolveMigrationsDir(), found)) + if err != nil { + t.Fatal(err) + } + text := string(raw) + if !strings.Contains(text, "-- +goose Up") || !strings.Contains(text, "-- +goose Down") { + t.Fatal("00044 must have Up and Down") + } + for _, table := range []string{"intent_plans", "intent_plan_steps", "intent_plan_decisions"} { + if !strings.Contains(text, table) { + t.Fatalf("00044 must mention %s", table) + } + } + for _, idx := range []string{"uq_intent_plans_turn", "uq_intent_plans_one_active_write", "uq_intent_plan_decisions_nonce"} { + if !strings.Contains(text, idx) { + t.Fatalf("00044 must mention %s", idx) + } + } +} + func TestGooseM2_00034to00036_DownThenUp(t *testing.T) { if testing.Short() { t.Skip("goose down is not part of -short") @@ -132,6 +167,7 @@ func TestGooseM2_00034to00036_DownThenUp(t *testing.T) { "00036_2026-09-14-final-artifact-vault.sql", "00037_2026-09-15-task-run-manifests.sql", "00038_2026-09-15-dataset-lifecycle.sql", + "00044_2026-09-15-intent-plans.sql", } joined := strings.Join(files, "\n") for _, name := range want {