diff --git a/CLAUDE.md b/CLAUDE.md
index 21cee194..daad3e41 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -59,7 +59,8 @@ internal/
gatewayrpc/ shared gRPC transport contract (control-plane <-> agent)
dbsqlc/ sqlc-generated DB layer — DO NOT edit manually
db/
- migrations/ PostgreSQL schema
+ migrations/ goose-миграции, два дерева: postgres/ (источник для
+ sqlc) + sqlite/ — правила в db/migrations/README.md
queries/ SQL source for sqlc
proto/ Protobuf definitions for gRPC gateway
web/ React dashboard (web/src/ui/ is the in-tree UI kit)
diff --git a/cmd/control-plane/backup.go b/cmd/control-plane/backup.go
index c933965f..510b808f 100644
--- a/cmd/control-plane/backup.go
+++ b/cmd/control-plane/backup.go
@@ -369,6 +369,11 @@ func verifyRestoreArchive(ctx context.Context, archivePath, storageDriver, stora
if err != nil {
return err
}
+ // NB: после P9-squash свежие БД несут goose-версию 1, а архивы,
+ // снятые до squash, — версии <= 58: для такой пары сравнение
+ // "ahead/older" инвертируется, хотя схемы бит-эквивалентны.
+ // Pre-prod: принято, восстановление до-squash архивов не
+ // поддерживается (пересоздать стенд).
switch {
case meta.SchemaVersion > targetVersion:
fmt.Printf(" schema: archive (v%d) is AHEAD of target (v%d) — run migrate-schema after restoring\n", meta.SchemaVersion, targetVersion)
diff --git a/cmd/sqlite-rebuild/main.go b/cmd/sqlite-rebuild/main.go
new file mode 100644
index 00000000..95cd43d8
--- /dev/null
+++ b/cmd/sqlite-rebuild/main.go
@@ -0,0 +1,77 @@
+// Command sqlite-rebuild печатает готовый goose-файл пересборки SQLite-таблицы
+// (create/copy/drop/rename/index в crash-safe транзакционных рамках).
+//
+// Использование (одна таблица за вызов; для нескольких таблиц в одной
+// миграции — объединить блоки между PRAGMA-строками вручную):
+//
+// go run ./cmd/sqlite-rebuild \
+// -table jobs \
+// -create new_jobs.sql \
+// -columns id,action,payload_json \
+// -index 'CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs (status);' \
+// > db/migrations/sqlite/0059_jobs_add_check.sql
+//
+// Флаг -copy file.sql заменяет дефолтный INSERT..SELECT кастомным (backfill).
+package main
+
+import (
+ "flag"
+ "fmt"
+ "os"
+ "strings"
+)
+
+type stringSlice []string
+
+func (s *stringSlice) String() string { return strings.Join(*s, "; ") }
+func (s *stringSlice) Set(v string) error {
+ *s = append(*s, v)
+ return nil
+}
+
+func main() {
+ table := flag.String("table", "", "имя пересобираемой таблицы (обязателен)")
+ createFile := flag.String("create", "", "файл с CREATE TABLE
_new (...) (обязателен)")
+ columns := flag.String("columns", "", "общие колонки через запятую (обязателен без -copy)")
+ copyFile := flag.String("copy", "", "файл с кастомным INSERT..SELECT (вместо -columns)")
+ var indexes stringSlice
+ flag.Var(&indexes, "index", "CREATE INDEX statement (повторяемый флаг)")
+ flag.Parse()
+
+ if err := run(*table, *createFile, *columns, *copyFile, indexes); err != nil {
+ fmt.Fprintln(os.Stderr, "sqlite-rebuild:", err)
+ os.Exit(1)
+ }
+}
+
+func run(table, createFile, columns, copyFile string, indexes []string) error {
+ if table == "" || createFile == "" {
+ return fmt.Errorf("-table and -create are required")
+ }
+ createSQL, err := os.ReadFile(createFile) //nolint:gosec // reason: local dev tool; the operator supplies the CREATE-TABLE file path deliberately.
+ if err != nil {
+ return err
+ }
+ spec := Spec{Table: table, CreateSQL: string(createSQL), Indexes: indexes}
+ switch {
+ case copyFile != "":
+ copySQL, err := os.ReadFile(copyFile) //nolint:gosec // reason: local dev tool; the operator supplies the custom-copy file path deliberately.
+ if err != nil {
+ return err
+ }
+ spec.CopySQL = string(copySQL)
+ case columns != "":
+ for _, c := range strings.Split(columns, ",") {
+ spec.Columns = append(spec.Columns, strings.TrimSpace(c))
+ }
+ default:
+ return fmt.Errorf("either -columns or -copy is required")
+ }
+
+ script, err := Script([]Spec{spec})
+ if err != nil {
+ return err
+ }
+ _, err = os.Stdout.WriteString(script)
+ return err
+}
diff --git a/cmd/sqlite-rebuild/script.go b/cmd/sqlite-rebuild/script.go
new file mode 100644
index 00000000..d90ffeb3
--- /dev/null
+++ b/cmd/sqlite-rebuild/script.go
@@ -0,0 +1,107 @@
+package main
+
+import (
+ "errors"
+ "fmt"
+ "strings"
+)
+
+// Spec описывает пересборку одной таблицы по рецепту
+// create/copy/drop/rename/index.
+type Spec struct {
+ // Table — имя пересобираемой таблицы (например "jobs").
+ Table string
+ // CreateSQL — полный CREATE TABLE _new (...) с новой схемой.
+ CreateSQL string
+ // Columns — общие колонки для дефолтного копирования
+ // INSERT INTO _new (cols) SELECT cols FROM .
+ // Игнорируется, если задан CopySQL.
+ Columns []string
+ // CopySQL — полный кастомный INSERT ... SELECT ... (с backfill'ом,
+ // CASE-преобразованиями и т.п.), когда прямого копирования мало.
+ CopySQL string
+ // Indexes — CREATE INDEX statements, воссоздаваемые после RENAME
+ // (DROP TABLE уносит индексы старой таблицы вместе с ней).
+ Indexes []string
+}
+
+const scriptHeader = `-- +goose Up
+-- +goose NO TRANSACTION
+-- Сгенерировано cmd/sqlite-rebuild. Рецепт пересборки таблицы (SQLite не
+-- умеет ALTER TABLE ADD/DROP CONSTRAINT): create/copy/drop/rename/index.
+-- Каждая пара DROP/RENAME — в собственном явном BEGIN/COMMIT, чтобы крэш
+-- между ними не оставил таблицу удалённой-но-не-переименованной (guard:
+-- migrate.TestSQLiteTableRebuildsAreTransactionWrapped). PRAGMA
+-- foreign_keys переключается ВНЕ транзакций — SQLite запрещает менять его
+-- внутри, поэтому весь файл идёт под NO TRANSACTION.
+
+PRAGMA foreign_keys = OFF;
+`
+
+const scriptFooter = `
+PRAGMA foreign_keys = ON;
+
+-- +goose Down
+-- Обратная пересборка не автоматизируется: напиши обратный rebuild вручную
+-- или оставь no-op, если даунгрейд не поддерживается.
+SELECT 1;
+`
+
+// Script собирает готовый goose-файл из одной или нескольких пересборок.
+func Script(specs []Spec) (string, error) {
+ if len(specs) == 0 {
+ return "", errors.New("at least one Spec is required")
+ }
+ var b strings.Builder
+ b.WriteString(scriptHeader)
+ for _, s := range specs {
+ block, err := rebuildBlock(s)
+ if err != nil {
+ return "", err
+ }
+ b.WriteString(block)
+ }
+ b.WriteString(scriptFooter)
+ return b.String(), nil
+}
+
+func rebuildBlock(s Spec) (string, error) {
+ if strings.TrimSpace(s.Table) == "" {
+ return "", errors.New("Spec.Table is required")
+ }
+ newName := s.Table + "_new"
+ if !strings.Contains(s.CreateSQL, newName) {
+ return "", fmt.Errorf("Spec.CreateSQL for %q must create %q (got: %.60s...)", s.Table, newName, s.CreateSQL)
+ }
+ copyStmt := strings.TrimSpace(s.CopySQL)
+ if copyStmt == "" {
+ if len(s.Columns) == 0 {
+ return "", fmt.Errorf("Spec for %q needs Columns or CopySQL", s.Table)
+ }
+ cols := strings.Join(s.Columns, ", ")
+ copyStmt = fmt.Sprintf("INSERT INTO %s (%s)\nSELECT %s FROM %s;", newName, cols, cols, s.Table)
+ }
+
+ var b strings.Builder
+ fmt.Fprintf(&b, "\n-- ─── %s ───\nBEGIN;\n\n", s.Table)
+ b.WriteString(ensureSemicolon(s.CreateSQL))
+ b.WriteString("\n\n")
+ b.WriteString(ensureSemicolon(copyStmt))
+ b.WriteString("\n\n")
+ fmt.Fprintf(&b, "DROP TABLE %s;\n", s.Table)
+ fmt.Fprintf(&b, "ALTER TABLE %s RENAME TO %s;\n", newName, s.Table)
+ if len(s.Indexes) > 0 {
+ b.WriteString("\n")
+ for _, idx := range s.Indexes {
+ b.WriteString(ensureSemicolon(idx))
+ b.WriteString("\n")
+ }
+ }
+ b.WriteString("\nCOMMIT;\n")
+ return b.String(), nil
+}
+
+func ensureSemicolon(stmt string) string {
+ trimmed := strings.TrimRight(strings.TrimSpace(stmt), ";")
+ return trimmed + ";"
+}
diff --git a/cmd/sqlite-rebuild/script_test.go b/cmd/sqlite-rebuild/script_test.go
new file mode 100644
index 00000000..c8b023bf
--- /dev/null
+++ b/cmd/sqlite-rebuild/script_test.go
@@ -0,0 +1,160 @@
+package main
+
+import (
+ "context"
+ "database/sql"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ // register the pure-Go SQLite driver under "sqlite" for database/sql
+ _ "modernc.org/sqlite"
+)
+
+func TestScriptGolden(t *testing.T) {
+ got, err := Script([]Spec{{
+ Table: "jobs",
+ CreateSQL: `CREATE TABLE jobs_new (
+ id TEXT PRIMARY KEY,
+ payload_json TEXT NOT NULL DEFAULT ''
+ CHECK (payload_json = '' OR json_valid(payload_json))
+);`,
+ Columns: []string{"id", "payload_json"},
+ Indexes: []string{"CREATE INDEX IF NOT EXISTS idx_jobs_payload ON jobs (payload_json);"},
+ }})
+ if err != nil {
+ t.Fatalf("Script: %v", err)
+ }
+
+ want := `-- +goose Up
+-- +goose NO TRANSACTION
+-- Сгенерировано cmd/sqlite-rebuild. Рецепт пересборки таблицы (SQLite не
+-- умеет ALTER TABLE ADD/DROP CONSTRAINT): create/copy/drop/rename/index.
+-- Каждая пара DROP/RENAME — в собственном явном BEGIN/COMMIT, чтобы крэш
+-- между ними не оставил таблицу удалённой-но-не-переименованной (guard:
+-- migrate.TestSQLiteTableRebuildsAreTransactionWrapped). PRAGMA
+-- foreign_keys переключается ВНЕ транзакций — SQLite запрещает менять его
+-- внутри, поэтому весь файл идёт под NO TRANSACTION.
+
+PRAGMA foreign_keys = OFF;
+
+-- ─── jobs ───
+BEGIN;
+
+CREATE TABLE jobs_new (
+ id TEXT PRIMARY KEY,
+ payload_json TEXT NOT NULL DEFAULT ''
+ CHECK (payload_json = '' OR json_valid(payload_json))
+);
+
+INSERT INTO jobs_new (id, payload_json)
+SELECT id, payload_json FROM jobs;
+
+DROP TABLE jobs;
+ALTER TABLE jobs_new RENAME TO jobs;
+
+CREATE INDEX IF NOT EXISTS idx_jobs_payload ON jobs (payload_json);
+
+COMMIT;
+
+PRAGMA foreign_keys = ON;
+
+-- +goose Down
+-- Обратная пересборка не автоматизируется: напиши обратный rebuild вручную
+-- или оставь no-op, если даунгрейд не поддерживается.
+SELECT 1;
+`
+ if got != want {
+ t.Fatalf("golden mismatch:\n--- got ---\n%s\n--- want ---\n%s", got, want)
+ }
+}
+
+func TestScriptValidation(t *testing.T) {
+ if _, err := Script([]Spec{{Table: "jobs", CreateSQL: "CREATE TABLE wrong_name (id TEXT);", Columns: []string{"id"}}}); err == nil {
+ t.Fatal("CreateSQL without _new must be rejected")
+ }
+ if _, err := Script([]Spec{{Table: "jobs", CreateSQL: "CREATE TABLE jobs_new (id TEXT);"}}); err == nil {
+ t.Fatal("Spec without Columns and without CopySQL must be rejected")
+ }
+ if _, err := Script(nil); err == nil {
+ t.Fatal("empty spec list must be rejected")
+ }
+}
+
+// TestScriptFunctional applies a generated script to a live DB with FK
+// references, rows and an index, and proves the rebuild is lossless.
+func TestScriptFunctional(t *testing.T) {
+ ctx := context.Background()
+ db, err := sql.Open("sqlite", "file:"+filepath.Join(t.TempDir(), "rebuild.db"))
+ if err != nil {
+ t.Fatalf("open: %v", err)
+ }
+ defer db.Close()
+ db.SetMaxOpenConns(1)
+
+ setup := []string{
+ `PRAGMA foreign_keys = ON;`,
+ `CREATE TABLE parents (id TEXT PRIMARY KEY);`,
+ `CREATE TABLE children (
+ id TEXT PRIMARY KEY,
+ parent_id TEXT NOT NULL REFERENCES parents (id) ON DELETE CASCADE,
+ note TEXT NOT NULL DEFAULT ''
+ );`,
+ `CREATE INDEX idx_children_parent ON children (parent_id);`,
+ `INSERT INTO parents (id) VALUES ('p1');`,
+ `INSERT INTO children (id, parent_id, note) VALUES ('c1', 'p1', 'keep'), ('c2', 'p1', 'also');`,
+ }
+ for _, stmt := range setup {
+ if _, err := db.ExecContext(ctx, stmt); err != nil {
+ t.Fatalf("setup %q: %v", stmt, err)
+ }
+ }
+
+ script, err := Script([]Spec{{
+ Table: "children",
+ CreateSQL: `CREATE TABLE children_new (
+ id TEXT PRIMARY KEY,
+ parent_id TEXT NOT NULL REFERENCES parents (id) ON DELETE CASCADE,
+ note TEXT NOT NULL DEFAULT '' CHECK (length(note) <= 64)
+);`,
+ Columns: []string{"id", "parent_id", "note"},
+ Indexes: []string{"CREATE INDEX IF NOT EXISTS idx_children_parent ON children (parent_id);"},
+ }})
+ if err != nil {
+ t.Fatalf("Script: %v", err)
+ }
+ // Отрезаем goose-аннотации: вне goose это обычный многостейтментный SQL.
+ // Якорь — сам statement "PRAGMA foreign_keys = OFF", а не слово "PRAGMA"
+ // из шапки-комментария, которое встречается раньше.
+ body := script[strings.Index(script, "PRAGMA foreign_keys = OFF"):strings.Index(script, "-- +goose Down")]
+ if _, err := db.ExecContext(ctx, body); err != nil {
+ t.Fatalf("apply generated script: %v", err)
+ }
+
+ var n int
+ if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM children`).Scan(&n); err != nil || n != 2 {
+ t.Fatalf("rows after rebuild: n=%d err=%v", n, err)
+ }
+ var ddl string
+ if err := db.QueryRowContext(ctx, `SELECT sql FROM sqlite_master WHERE type='table' AND name='children'`).Scan(&ddl); err != nil {
+ t.Fatalf("read rebuilt DDL: %v", err)
+ }
+ if !strings.Contains(ddl, "length(note) <= 64") {
+ t.Fatalf("rebuilt table lost the new CHECK: %s", ddl)
+ }
+ var idx string
+ if err := db.QueryRowContext(ctx, `SELECT name FROM sqlite_master WHERE type='index' AND name='idx_children_parent'`).Scan(&idx); err != nil {
+ t.Fatalf("index not recreated: %v", err)
+ }
+ rows, err := db.QueryContext(ctx, `PRAGMA foreign_key_check`)
+ if err != nil {
+ t.Fatalf("foreign_key_check: %v", err)
+ }
+ defer rows.Close()
+ if rows.Next() {
+ t.Fatal("foreign_key_check reported violations after rebuild")
+ }
+ if err := rows.Err(); err != nil {
+ t.Fatalf("foreign_key_check rows: %v", err)
+ }
+}
diff --git a/db/migrations/README.md b/db/migrations/README.md
new file mode 100644
index 00000000..ae62fdab
--- /dev/null
+++ b/db/migrations/README.md
@@ -0,0 +1,44 @@
+# Миграции схемы
+
+Два дерева goose-миграций — по одному на диалект:
+
+- `postgres/` — источник схемы для sqlc (`sqlc.yaml` читает эту директорию);
+- `sqlite/` — зеркальное дерево для SQLite-бэкенда.
+
+## Squash 2026-07 (P9)
+
+Миграции 0001..0058 обоих деревьев консолидированы в `0001_init.sql`
+(SQLite — дампом sqlite_master, PostgreSQL — `pg_dump --schema-only`;
+эквивалентность доказана schema_sync-тестом и нулевым sqlc-диффом).
+БД, созданные до squash, несут goose-версии 1..58 и пропускают 0001.
+
+## Правила (проверяются `storage/migrate/migration_parity_lint_test.go`)
+
+1. **Номер новой миграции >= 0059** (`squashedHistoryCeiling+1`). Номера
+ 2..58 запрещены: на до-squash БД goose молча пропустил бы такой файл.
+2. **Один номер = одно логическое изменение в обоих деревьях**, файлы
+ называются одинаково: `NNNN_the_change.sql` и там и там.
+3. Миграция, нужная только одному диалекту, содержит строку
+ `-- dialect-only: <причина>`; её номер зарезервирован в обоих деревьях.
+4. PostgreSQL: `CREATE INDEX` на существующей таблице — только
+ `CONCURRENTLY` + `-- +goose NO TRANSACTION`
+ (`TestPostgresIndexesUseConcurrently`).
+5. SQLite: пересборка таблицы (ADD CONSTRAINT и прочее, чего SQLite не
+ умеет через ALTER) — генерируй файл инструментом:
+
+ ```bash
+ go run ./cmd/sqlite-rebuild -table jobs -create new_jobs.sql \
+ -columns id,action,payload_json \
+ -index 'CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs (status);' \
+ > db/migrations/sqlite/0059_jobs_add_check.sql
+ ```
+
+ Рецепт (BEGIN до DROP, COMMIT после RENAME, PRAGMA вне транзакций)
+ стережёт `TestSQLiteTableRebuildsAreTransactionWrapped`.
+6. После любой правки `postgres/` — `sqlc generate` (+ коммит диффа
+ `internal/dbsqlc/`); после любой правки схемы — зелёный
+ `TestSchemaSyncPostgresMatchesSQLite` (нужен `PANVEX_POSTGRES_TEST_DSN`,
+ в CI поднят сервис-контейнер).
+7. Деструктивная миграция (DROP/TRUNCATE/DELETE по живым таблицам) —
+ регистрируется в `storage/migrateguard.DestructiveMigrations` со своим
+ goose-номером.
diff --git a/db/migrations/postgres/0001_init.sql b/db/migrations/postgres/0001_init.sql
index b33bdda5..4f073134 100644
--- a/db/migrations/postgres/0001_init.sql
+++ b/db/migrations/postgres/0001_init.sql
@@ -1,283 +1,857 @@
-- +goose Up
-CREATE TABLE IF NOT EXISTS fleet_groups (
- id TEXT PRIMARY KEY,
- name TEXT NOT NULL,
- created_at TIMESTAMPTZ NOT NULL
-);
-
-CREATE TABLE IF NOT EXISTS users (
- id TEXT PRIMARY KEY,
- username TEXT NOT NULL UNIQUE,
- password_hash TEXT NOT NULL,
- role TEXT NOT NULL,
- totp_enabled BOOLEAN NOT NULL DEFAULT FALSE,
- totp_secret TEXT NOT NULL DEFAULT '',
- created_at TIMESTAMPTZ NOT NULL
-);
-
-CREATE TABLE IF NOT EXISTS user_appearance (
- user_id TEXT PRIMARY KEY REFERENCES users (id) ON DELETE CASCADE,
- theme TEXT NOT NULL DEFAULT 'system',
- density TEXT NOT NULL DEFAULT 'comfortable',
- help_mode TEXT NOT NULL DEFAULT 'basic',
- updated_at TIMESTAMPTZ NOT NULL DEFAULT TIMESTAMPTZ 'epoch'
-);
-
-CREATE TABLE IF NOT EXISTS agents (
- id TEXT PRIMARY KEY,
- node_name TEXT NOT NULL,
- fleet_group_id TEXT REFERENCES fleet_groups (id),
- version TEXT NOT NULL DEFAULT '',
- read_only BOOLEAN NOT NULL DEFAULT FALSE,
- last_seen_at TIMESTAMPTZ NOT NULL,
- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
-);
-
-CREATE TABLE IF NOT EXISTS telemt_instances (
- id TEXT PRIMARY KEY,
- agent_id TEXT NOT NULL REFERENCES agents (id),
- name TEXT NOT NULL,
- version TEXT NOT NULL DEFAULT '',
- config_fingerprint TEXT NOT NULL DEFAULT '',
- connected_users BIGINT NOT NULL DEFAULT 0,
- read_only BOOLEAN NOT NULL DEFAULT FALSE,
- updated_at TIMESTAMPTZ NOT NULL
-);
-
-CREATE TABLE IF NOT EXISTS telemt_runtime_current (
- agent_id TEXT PRIMARY KEY REFERENCES agents (id) ON DELETE CASCADE,
- observed_at TIMESTAMPTZ NOT NULL,
- state TEXT NOT NULL DEFAULT '',
- state_reason TEXT NOT NULL DEFAULT '',
- read_only BOOLEAN NOT NULL DEFAULT FALSE,
- accepting_new_connections BOOLEAN NOT NULL DEFAULT FALSE,
- me_runtime_ready BOOLEAN NOT NULL DEFAULT FALSE,
- me2dc_fallback_enabled BOOLEAN NOT NULL DEFAULT FALSE,
- use_middle_proxy BOOLEAN NOT NULL DEFAULT FALSE,
- startup_status TEXT NOT NULL DEFAULT '',
- startup_stage TEXT NOT NULL DEFAULT '',
- startup_progress_pct DOUBLE PRECISION NOT NULL DEFAULT 0,
- initialization_status TEXT NOT NULL DEFAULT '',
- degraded BOOLEAN NOT NULL DEFAULT FALSE,
- initialization_stage TEXT NOT NULL DEFAULT '',
- initialization_progress_pct DOUBLE PRECISION NOT NULL DEFAULT 0,
- transport_mode TEXT NOT NULL DEFAULT '',
- current_connections BIGINT NOT NULL DEFAULT 0,
- current_connections_me BIGINT NOT NULL DEFAULT 0,
- current_connections_direct BIGINT NOT NULL DEFAULT 0,
- active_users BIGINT NOT NULL DEFAULT 0,
- uptime_seconds DOUBLE PRECISION NOT NULL DEFAULT 0,
- connections_total BIGINT NOT NULL DEFAULT 0,
- connections_bad_total BIGINT NOT NULL DEFAULT 0,
- handshake_timeouts_total BIGINT NOT NULL DEFAULT 0,
- configured_users BIGINT NOT NULL DEFAULT 0,
- dc_coverage_pct DOUBLE PRECISION NOT NULL DEFAULT 0,
- healthy_upstreams BIGINT NOT NULL DEFAULT 0,
- total_upstreams BIGINT NOT NULL DEFAULT 0
-);
-
-CREATE TABLE IF NOT EXISTS telemt_runtime_dcs_current (
- agent_id TEXT NOT NULL REFERENCES agents (id) ON DELETE CASCADE,
- dc BIGINT NOT NULL,
- observed_at TIMESTAMPTZ NOT NULL,
- available_endpoints BIGINT NOT NULL DEFAULT 0,
- available_pct DOUBLE PRECISION NOT NULL DEFAULT 0,
- required_writers BIGINT NOT NULL DEFAULT 0,
- alive_writers BIGINT NOT NULL DEFAULT 0,
- coverage_pct DOUBLE PRECISION NOT NULL DEFAULT 0,
- rtt_ms DOUBLE PRECISION NOT NULL DEFAULT 0,
- load DOUBLE PRECISION NOT NULL DEFAULT 0,
- PRIMARY KEY (agent_id, dc)
-);
-
-CREATE TABLE IF NOT EXISTS telemt_runtime_upstreams_current (
- agent_id TEXT NOT NULL REFERENCES agents (id) ON DELETE CASCADE,
- upstream_id BIGINT NOT NULL,
- observed_at TIMESTAMPTZ NOT NULL,
- route_kind TEXT NOT NULL DEFAULT '',
- address TEXT NOT NULL DEFAULT '',
- healthy BOOLEAN NOT NULL DEFAULT FALSE,
- fails BIGINT NOT NULL DEFAULT 0,
- effective_latency_ms DOUBLE PRECISION NOT NULL DEFAULT 0,
- PRIMARY KEY (agent_id, upstream_id)
-);
-
--- Column name differs between drivers: SQLite uses timestamp_unix (INTEGER)
--- while Postgres uses timestamp_at (TIMESTAMPTZ). Query implementations in
--- each driver's telemetry.go must use the correct column name.
-CREATE TABLE IF NOT EXISTS telemt_runtime_events (
- agent_id TEXT NOT NULL REFERENCES agents (id) ON DELETE CASCADE,
- sequence BIGINT NOT NULL,
- observed_at TIMESTAMPTZ NOT NULL,
- timestamp_at TIMESTAMPTZ NOT NULL,
- event_type TEXT NOT NULL DEFAULT '',
- context TEXT NOT NULL DEFAULT '',
- severity TEXT NOT NULL DEFAULT '',
- PRIMARY KEY (agent_id, sequence)
-);
-
-CREATE TABLE IF NOT EXISTS telemt_diagnostics_current (
- agent_id TEXT PRIMARY KEY REFERENCES agents (id) ON DELETE CASCADE,
- observed_at TIMESTAMPTZ NOT NULL,
- state TEXT NOT NULL DEFAULT '',
- state_reason TEXT NOT NULL DEFAULT '',
- system_info_json TEXT NOT NULL DEFAULT '{}',
- effective_limits_json TEXT NOT NULL DEFAULT '{}',
- security_posture_json TEXT NOT NULL DEFAULT '{}',
- minimal_all_json TEXT NOT NULL DEFAULT '{}',
- me_pool_json TEXT NOT NULL DEFAULT '{}',
- dcs_json TEXT NOT NULL DEFAULT '{}'
-);
-
-CREATE TABLE IF NOT EXISTS telemt_security_inventory_current (
- agent_id TEXT PRIMARY KEY REFERENCES agents (id) ON DELETE CASCADE,
- observed_at TIMESTAMPTZ NOT NULL,
- state TEXT NOT NULL DEFAULT '',
- state_reason TEXT NOT NULL DEFAULT '',
- enabled BOOLEAN NOT NULL DEFAULT FALSE,
- entries_total BIGINT NOT NULL DEFAULT 0,
- entries_json TEXT NOT NULL DEFAULT '[]'
-);
-
-CREATE TABLE IF NOT EXISTS telemt_detail_boosts (
- agent_id TEXT PRIMARY KEY REFERENCES agents (id) ON DELETE CASCADE,
- expires_at TIMESTAMPTZ NOT NULL,
- updated_at TIMESTAMPTZ NOT NULL
-);
-
-CREATE TABLE IF NOT EXISTS jobs (
- id TEXT PRIMARY KEY,
- action TEXT NOT NULL,
- idempotency_key TEXT NOT NULL UNIQUE,
- actor_id TEXT NOT NULL,
- status TEXT NOT NULL,
- created_at TIMESTAMPTZ NOT NULL,
- ttl_nanos BIGINT NOT NULL,
- payload_json TEXT NOT NULL DEFAULT ''
-);
-
-CREATE TABLE IF NOT EXISTS job_targets (
- job_id TEXT NOT NULL REFERENCES jobs (id),
- agent_id TEXT NOT NULL,
- status TEXT NOT NULL,
- result_text TEXT NOT NULL DEFAULT '',
- result_json TEXT NOT NULL DEFAULT '',
- updated_at TIMESTAMPTZ NOT NULL,
- PRIMARY KEY (job_id, agent_id)
-);
-
-CREATE TABLE IF NOT EXISTS audit_events (
- id TEXT PRIMARY KEY,
- actor_id TEXT NOT NULL,
- action TEXT NOT NULL,
- target_id TEXT NOT NULL,
- details JSONB NOT NULL DEFAULT '{}'::jsonb,
- created_at TIMESTAMPTZ NOT NULL
-);
-
-CREATE TABLE IF NOT EXISTS metric_snapshots (
- id TEXT PRIMARY KEY,
- agent_id TEXT NOT NULL REFERENCES agents (id),
- instance_id TEXT NOT NULL DEFAULT '',
- captured_at TIMESTAMPTZ NOT NULL,
- values JSONB NOT NULL
-);
-
-CREATE TABLE IF NOT EXISTS enrollment_tokens (
- value TEXT PRIMARY KEY,
- fleet_group_id TEXT,
- issued_at TIMESTAMPTZ NOT NULL,
- expires_at TIMESTAMPTZ NOT NULL,
- consumed_at TIMESTAMPTZ,
- revoked_at TIMESTAMPTZ
-);
-
-CREATE TABLE IF NOT EXISTS agent_certificate_recovery_grants (
- agent_id TEXT PRIMARY KEY REFERENCES agents (id),
- issued_by TEXT NOT NULL,
- issued_at TIMESTAMPTZ NOT NULL,
- expires_at TIMESTAMPTZ NOT NULL,
- used_at TIMESTAMPTZ,
- revoked_at TIMESTAMPTZ
-);
-
-CREATE TABLE IF NOT EXISTS clients (
- id TEXT PRIMARY KEY,
- name TEXT NOT NULL,
- secret_ciphertext TEXT NOT NULL,
- user_ad_tag TEXT NOT NULL,
- enabled BOOLEAN NOT NULL DEFAULT TRUE,
- max_tcp_conns BIGINT NOT NULL DEFAULT 0,
- max_unique_ips BIGINT NOT NULL DEFAULT 0,
- data_quota_bytes BIGINT NOT NULL DEFAULT 0,
- expiration_rfc3339 TEXT NOT NULL DEFAULT '',
- created_at TIMESTAMPTZ NOT NULL,
- updated_at TIMESTAMPTZ NOT NULL,
- deleted_at TIMESTAMPTZ
-);
-
-CREATE TABLE IF NOT EXISTS client_assignments (
- id TEXT PRIMARY KEY,
- client_id TEXT NOT NULL REFERENCES clients (id) ON DELETE CASCADE,
- target_type TEXT NOT NULL,
- fleet_group_id TEXT REFERENCES fleet_groups (id),
- agent_id TEXT REFERENCES agents (id),
- created_at TIMESTAMPTZ NOT NULL
-);
-
-CREATE TABLE IF NOT EXISTS client_deployments (
- client_id TEXT NOT NULL REFERENCES clients (id) ON DELETE CASCADE,
- agent_id TEXT NOT NULL REFERENCES agents (id),
- desired_operation TEXT NOT NULL,
- status TEXT NOT NULL,
- last_error TEXT NOT NULL DEFAULT '',
- connection_link TEXT NOT NULL DEFAULT '',
- last_applied_at TIMESTAMPTZ,
- updated_at TIMESTAMPTZ NOT NULL,
- PRIMARY KEY (client_id, agent_id)
-);
-
-CREATE TABLE IF NOT EXISTS panel_settings (
- scope TEXT PRIMARY KEY,
- http_public_url TEXT NOT NULL DEFAULT '',
- http_root_path TEXT NOT NULL DEFAULT '',
- grpc_public_endpoint TEXT NOT NULL DEFAULT '',
- http_listen_address TEXT NOT NULL DEFAULT '',
- grpc_listen_address TEXT NOT NULL DEFAULT '',
- tls_mode TEXT NOT NULL DEFAULT '',
- tls_cert_file TEXT NOT NULL DEFAULT '',
- tls_key_file TEXT NOT NULL DEFAULT '',
- updated_at TIMESTAMPTZ NOT NULL
-);
-
-CREATE TABLE IF NOT EXISTS certificate_authority (
- scope TEXT PRIMARY KEY,
- ca_pem TEXT NOT NULL,
- private_key_pem TEXT NOT NULL,
- updated_at TIMESTAMPTZ NOT NULL
+-- P9 squash (2026-07): консолидация миграций 0001..0058 в один init.
+-- Файл сгенерирован pg_dump --schema-only с БД, смигрированной полным
+-- историческим деревом (задача P9-4). НЕ редактировать задним числом:
+-- изменения схемы = новая миграция с номером >= 0059 (README рядом).
+
+
+CREATE TABLE public.agent_certificate_recovery_grants (
+ agent_id text NOT NULL,
+ issued_by text NOT NULL,
+ issued_at timestamp with time zone NOT NULL,
+ expires_at timestamp with time zone NOT NULL,
+ used_at timestamp with time zone,
+ revoked_at timestamp with time zone
+);
+
+CREATE TABLE public.agent_config_targets (
+ scope_type text NOT NULL,
+ scope_id text NOT NULL,
+ sections_json text DEFAULT '{}'::text NOT NULL,
+ created_at timestamp with time zone NOT NULL,
+ updated_at timestamp with time zone NOT NULL
+);
+
+CREATE TABLE public.agent_fallback_state (
+ agent_id text NOT NULL,
+ entered_at_unix bigint NOT NULL
+);
+
+CREATE TABLE public.agent_revocations (
+ agent_id text NOT NULL,
+ revoked_at timestamp with time zone DEFAULT now() NOT NULL,
+ cert_expires_at timestamp with time zone NOT NULL
+);
+
+CREATE TABLE public.agents (
+ id text NOT NULL,
+ node_name text NOT NULL,
+ fleet_group_id uuid,
+ version text DEFAULT ''::text NOT NULL,
+ read_only boolean DEFAULT false NOT NULL,
+ last_seen_at timestamp with time zone NOT NULL,
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
+ cert_issued_at timestamp with time zone,
+ cert_expires_at timestamp with time zone,
+ cert_serial text DEFAULT ''::text NOT NULL,
+ transport_mode text DEFAULT 'inbound'::text NOT NULL,
+ dial_address text,
+ bootstrap_state text DEFAULT 'active'::text NOT NULL,
+ bootstrap_token_hash bytea,
+ bootstrap_expires_at timestamp with time zone,
+ cert_spki_sha256 bytea DEFAULT '\x'::bytea NOT NULL,
+ CONSTRAINT agents_bootstrap_state_check CHECK ((bootstrap_state = ANY (ARRAY['pending'::text, 'active'::text, 'expired'::text, 'revoked'::text]))),
+ CONSTRAINT agents_cert_spki_sha256_check CHECK ((length(cert_spki_sha256) = ANY (ARRAY[0, 32]))),
+ CONSTRAINT agents_transport_mode_check CHECK ((transport_mode = ANY (ARRAY['inbound'::text, 'outbound'::text])))
+);
+
+CREATE TABLE public.audit_events (
+ id text NOT NULL,
+ actor_id text NOT NULL,
+ action text NOT NULL,
+ target_id text NOT NULL,
+ details jsonb DEFAULT '{}'::jsonb NOT NULL,
+ created_at timestamp with time zone NOT NULL,
+ prev_hash text DEFAULT ''::text NOT NULL,
+ event_hash text DEFAULT ''::text NOT NULL
+);
+
+CREATE TABLE public.certificate_authority (
+ scope text NOT NULL,
+ ca_pem text NOT NULL,
+ private_key_pem text NOT NULL,
+ updated_at timestamp with time zone NOT NULL
+);
+
+CREATE TABLE public.client_assignments (
+ id text NOT NULL,
+ client_id text NOT NULL,
+ target_type text NOT NULL,
+ fleet_group_id uuid,
+ agent_id text,
+ created_at timestamp with time zone NOT NULL
+);
+
+CREATE TABLE public.client_deployments (
+ client_id text NOT NULL,
+ agent_id text NOT NULL,
+ desired_operation text NOT NULL,
+ status text NOT NULL,
+ last_error text DEFAULT ''::text NOT NULL,
+ last_applied_at timestamp with time zone,
+ updated_at timestamp with time zone NOT NULL,
+ connection_links jsonb DEFAULT '[]'::jsonb NOT NULL,
+ last_reset_epoch_secs bigint DEFAULT 0 NOT NULL,
+ link_diagnostic text DEFAULT ''::text NOT NULL
+);
+
+CREATE TABLE public.client_ip_history (
+ agent_id text NOT NULL,
+ client_id text NOT NULL,
+ ip_address text NOT NULL,
+ first_seen timestamp with time zone NOT NULL,
+ last_seen timestamp with time zone NOT NULL
+);
+
+CREATE TABLE public.client_usage (
+ client_id text NOT NULL,
+ agent_id text NOT NULL,
+ traffic_used_bytes bigint DEFAULT 0 NOT NULL,
+ unique_ips_used integer DEFAULT 0 NOT NULL,
+ active_tcp_conns integer DEFAULT 0 NOT NULL,
+ active_unique_ips integer DEFAULT 0 NOT NULL,
+ observed_at timestamp with time zone NOT NULL,
+ quota_used_bytes bigint DEFAULT 0 NOT NULL,
+ quota_last_reset_unix bigint DEFAULT 0 NOT NULL,
+ agent_boot_id text DEFAULT ''::text NOT NULL,
+ last_total_bytes bigint DEFAULT 0 NOT NULL
+);
+
+CREATE TABLE public.clients (
+ id text NOT NULL,
+ name text NOT NULL,
+ secret_ciphertext text NOT NULL,
+ user_ad_tag text NOT NULL,
+ enabled boolean DEFAULT true NOT NULL,
+ max_tcp_conns bigint DEFAULT 0 NOT NULL,
+ max_unique_ips bigint DEFAULT 0 NOT NULL,
+ data_quota_bytes bigint DEFAULT 0 NOT NULL,
+ expiration_rfc3339 text DEFAULT ''::text NOT NULL,
+ created_at timestamp with time zone NOT NULL,
+ updated_at timestamp with time zone NOT NULL,
+ deleted_at timestamp with time zone,
+ subscription_token text
+);
+
+CREATE TABLE public.config_apply_batch_targets (
+ batch_id text NOT NULL,
+ agent_id text NOT NULL,
+ wave_index integer NOT NULL,
+ job_id text DEFAULT ''::text NOT NULL,
+ status text NOT NULL,
+ message text DEFAULT ''::text NOT NULL,
+ CONSTRAINT config_apply_batch_targets_status_check CHECK ((status = ANY (ARRAY['pending'::text, 'running'::text, 'succeeded'::text, 'failed'::text, 'skipped'::text])))
+);
+
+CREATE TABLE public.config_apply_batches (
+ id text NOT NULL,
+ fleet_group_id uuid,
+ mode text NOT NULL,
+ wave_size integer DEFAULT 1 NOT NULL,
+ expected_revision text DEFAULT ''::text NOT NULL,
+ status text NOT NULL,
+ created_at timestamp with time zone NOT NULL,
+ updated_at timestamp with time zone NOT NULL,
+ CONSTRAINT config_apply_batches_mode_check CHECK ((mode = ANY (ARRAY['all_at_once'::text, 'rolling'::text]))),
+ CONSTRAINT config_apply_batches_status_check CHECK ((status = ANY (ARRAY['running'::text, 'succeeded'::text, 'failed'::text, 'halted'::text])))
+);
+
+CREATE TABLE public.consumed_totp (
+ user_id text NOT NULL,
+ code text NOT NULL,
+ used_at timestamp with time zone DEFAULT now() NOT NULL
+);
+
+CREATE TABLE public.cp_secrets (
+ key text NOT NULL,
+ value bytea NOT NULL,
+ updated_at timestamp with time zone DEFAULT now() NOT NULL
+);
+
+CREATE TABLE public.discovered_clients (
+ id text NOT NULL,
+ agent_id text NOT NULL,
+ client_name text NOT NULL,
+ secret text DEFAULT ''::text NOT NULL,
+ status text DEFAULT 'pending_review'::text NOT NULL,
+ total_octets bigint DEFAULT 0 NOT NULL,
+ current_connections integer DEFAULT 0 NOT NULL,
+ active_unique_ips integer DEFAULT 0 NOT NULL,
+ max_tcp_conns integer DEFAULT 0 NOT NULL,
+ max_unique_ips integer DEFAULT 0 NOT NULL,
+ data_quota_bytes bigint DEFAULT 0 NOT NULL,
+ expiration text DEFAULT ''::text NOT NULL,
+ discovered_at timestamp with time zone NOT NULL,
+ updated_at timestamp with time zone NOT NULL,
+ connection_links jsonb DEFAULT '[]'::jsonb NOT NULL,
+ CONSTRAINT discovered_clients_status_check CHECK ((status = ANY (ARRAY['pending_review'::text, 'adopted'::text, 'ignored'::text])))
+);
+
+CREATE TABLE public.enrollment_attempts (
+ id uuid NOT NULL,
+ token_id uuid,
+ agent_id uuid,
+ mode text NOT NULL,
+ client_addr text,
+ request_id text NOT NULL,
+ status text NOT NULL,
+ error_code text,
+ error_message text,
+ started_at timestamp with time zone NOT NULL,
+ finished_at timestamp with time zone,
+ CONSTRAINT enrollment_attempts_mode_check CHECK ((mode = ANY (ARRAY['inbound'::text, 'outbound'::text]))),
+ CONSTRAINT enrollment_attempts_status_check CHECK ((status = ANY (ARRAY['in_progress'::text, 'success'::text, 'failed'::text])))
+);
+
+CREATE TABLE public.enrollment_events (
+ id bigint NOT NULL,
+ attempt_id uuid NOT NULL,
+ ts timestamp with time zone NOT NULL,
+ step text NOT NULL,
+ level text NOT NULL,
+ message text,
+ fields_json jsonb,
+ CONSTRAINT enrollment_events_level_check CHECK ((level = ANY (ARRAY['info'::text, 'warn'::text, 'error'::text])))
+);
+
+CREATE SEQUENCE public.enrollment_events_id_seq
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+ALTER SEQUENCE public.enrollment_events_id_seq OWNED BY public.enrollment_events.id;
+
+CREATE TABLE public.enrollment_tokens (
+ value text NOT NULL,
+ fleet_group_id uuid,
+ issued_at timestamp with time zone NOT NULL,
+ expires_at timestamp with time zone NOT NULL,
+ consumed_at timestamp with time zone,
+ revoked_at timestamp with time zone
+);
+
+CREATE TABLE public.fleet_group_integrations (
+ id uuid NOT NULL,
+ fleet_group_id uuid NOT NULL,
+ kind text NOT NULL,
+ provider_id uuid,
+ config jsonb DEFAULT '{}'::jsonb NOT NULL,
+ enabled boolean DEFAULT false NOT NULL,
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
+ updated_at timestamp with time zone DEFAULT now() NOT NULL
+);
+
+CREATE TABLE public.fleet_groups (
+ id uuid NOT NULL,
+ name text NOT NULL,
+ created_at timestamp with time zone NOT NULL,
+ label text DEFAULT ''::text NOT NULL,
+ description text DEFAULT ''::text NOT NULL,
+ updated_at timestamp with time zone DEFAULT now() NOT NULL
+);
+
+CREATE TABLE public.integration_providers (
+ id uuid NOT NULL,
+ kind text NOT NULL,
+ label text DEFAULT ''::text NOT NULL,
+ config text DEFAULT '{}'::text NOT NULL,
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
+ updated_at timestamp with time zone DEFAULT now() NOT NULL,
+ CONSTRAINT integration_providers_config_check CHECK (((config ~~ 'PVS_:%'::text) OR (config IS JSON)))
+);
+
+CREATE TABLE public.job_targets (
+ job_id text NOT NULL,
+ agent_id text NOT NULL,
+ status text NOT NULL,
+ result_text text DEFAULT ''::text NOT NULL,
+ result_json text DEFAULT ''::text NOT NULL,
+ updated_at timestamp with time zone NOT NULL,
+ CONSTRAINT job_targets_status_check CHECK ((status = ANY (ARRAY['queued'::text, 'sent'::text, 'acknowledged'::text, 'succeeded'::text, 'failed'::text, 'expired'::text])))
+);
+
+CREATE TABLE public.jobs (
+ id text NOT NULL,
+ action text NOT NULL,
+ idempotency_key text NOT NULL,
+ actor_id text NOT NULL,
+ status text NOT NULL,
+ created_at timestamp with time zone NOT NULL,
+ ttl_nanos bigint NOT NULL,
+ payload_json text DEFAULT ''::text NOT NULL,
+ CONSTRAINT jobs_status_check CHECK ((status = ANY (ARRAY['queued'::text, 'running'::text, 'succeeded'::text, 'failed'::text, 'expired'::text, 'partial'::text])))
+);
+
+CREATE TABLE public.login_lockouts (
+ username text NOT NULL,
+ failures integer DEFAULT 0 NOT NULL,
+ locked_at timestamp with time zone,
+ updated_at timestamp with time zone DEFAULT now() NOT NULL
+);
+
+CREATE TABLE public.metric_snapshots (
+ id text NOT NULL,
+ agent_id text NOT NULL,
+ instance_id text DEFAULT ''::text NOT NULL,
+ captured_at timestamp with time zone NOT NULL,
+ "values" jsonb NOT NULL
);
+CREATE TABLE public.panel_settings (
+ scope text NOT NULL,
+ http_public_url text DEFAULT ''::text NOT NULL,
+ grpc_public_endpoint text DEFAULT ''::text NOT NULL,
+ updated_at timestamp with time zone NOT NULL,
+ retention_json text DEFAULT ''::text NOT NULL,
+ password_min_length integer DEFAULT 10 NOT NULL,
+ geoip_json text DEFAULT ''::text NOT NULL,
+ geoip_state_json text DEFAULT ''::text NOT NULL,
+ CONSTRAINT panel_settings_password_min_length_check CHECK (((password_min_length >= 8) AND (password_min_length <= 128)))
+);
+
+CREATE TABLE public.runtime_settings (
+ name text NOT NULL,
+ value_json text NOT NULL,
+ updated_at bigint NOT NULL,
+ updated_by text DEFAULT ''::text NOT NULL
+);
+
+CREATE TABLE public.sessions (
+ id text NOT NULL,
+ user_id text NOT NULL,
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
+ last_seen_at timestamp with time zone DEFAULT now() NOT NULL
+);
+
+CREATE TABLE public.telemt_diagnostics_current (
+ agent_id text NOT NULL,
+ observed_at timestamp with time zone NOT NULL,
+ state text DEFAULT ''::text NOT NULL,
+ state_reason text DEFAULT ''::text NOT NULL,
+ system_info_json text DEFAULT '{}'::text NOT NULL,
+ effective_limits_json text DEFAULT '{}'::text NOT NULL,
+ security_posture_json text DEFAULT '{}'::text NOT NULL,
+ minimal_all_json text DEFAULT '{}'::text NOT NULL,
+ me_pool_json text DEFAULT '{}'::text NOT NULL,
+ dcs_json text DEFAULT '{}'::text NOT NULL
+);
+
+CREATE TABLE public.telemt_instances (
+ id text NOT NULL,
+ agent_id text NOT NULL,
+ name text NOT NULL,
+ version text DEFAULT ''::text NOT NULL,
+ config_fingerprint text DEFAULT ''::text NOT NULL,
+ connections bigint DEFAULT 0 CONSTRAINT telemt_instances_connected_users_not_null NOT NULL,
+ read_only boolean DEFAULT false NOT NULL,
+ updated_at timestamp with time zone NOT NULL
+);
+
+CREATE TABLE public.telemt_runtime_current (
+ agent_id text NOT NULL,
+ observed_at timestamp with time zone NOT NULL,
+ runtime_json text DEFAULT ''::text NOT NULL
+);
+
+CREATE TABLE public.telemt_runtime_dcs_current (
+ agent_id text NOT NULL,
+ dc bigint NOT NULL,
+ observed_at timestamp with time zone NOT NULL,
+ available_endpoints bigint DEFAULT 0 NOT NULL,
+ available_pct double precision DEFAULT 0 NOT NULL,
+ required_writers bigint DEFAULT 0 NOT NULL,
+ alive_writers bigint DEFAULT 0 NOT NULL,
+ coverage_pct double precision DEFAULT 0 NOT NULL,
+ rtt_ms double precision DEFAULT 0 NOT NULL,
+ load double precision DEFAULT 0 NOT NULL
+);
+
+CREATE TABLE public.telemt_runtime_events (
+ agent_id text NOT NULL,
+ sequence bigint NOT NULL,
+ observed_at timestamp with time zone NOT NULL,
+ timestamp_at timestamp with time zone NOT NULL,
+ event_type text DEFAULT ''::text NOT NULL,
+ context text DEFAULT ''::text NOT NULL,
+ severity text DEFAULT ''::text NOT NULL
+);
+
+CREATE TABLE public.telemt_runtime_upstreams_current (
+ agent_id text NOT NULL,
+ upstream_id bigint NOT NULL,
+ observed_at timestamp with time zone NOT NULL,
+ route_kind text DEFAULT ''::text NOT NULL,
+ address text DEFAULT ''::text NOT NULL,
+ healthy boolean DEFAULT false NOT NULL,
+ fails bigint DEFAULT 0 NOT NULL,
+ effective_latency_ms double precision DEFAULT 0 NOT NULL
+);
+
+CREATE TABLE public.telemt_security_inventory_current (
+ agent_id text NOT NULL,
+ observed_at timestamp with time zone NOT NULL,
+ state text DEFAULT ''::text NOT NULL,
+ state_reason text DEFAULT ''::text NOT NULL,
+ enabled boolean DEFAULT false NOT NULL,
+ entries_total bigint DEFAULT 0 NOT NULL,
+ entries_json text DEFAULT '[]'::text NOT NULL
+);
+
+CREATE TABLE public.ts_dc_health (
+ agent_id text NOT NULL,
+ captured_at timestamp with time zone NOT NULL,
+ dc integer NOT NULL,
+ coverage_pct_avg real DEFAULT 0 NOT NULL,
+ coverage_pct_min real DEFAULT 0 NOT NULL,
+ rtt_ms_avg real DEFAULT 0 NOT NULL,
+ rtt_ms_max real DEFAULT 0 NOT NULL,
+ alive_writers_min integer DEFAULT 0 NOT NULL,
+ required_writers integer DEFAULT 0 NOT NULL,
+ load_max integer DEFAULT 0 NOT NULL,
+ sample_count integer DEFAULT 1 NOT NULL
+);
+
+CREATE TABLE public.ts_server_load (
+ agent_id text NOT NULL,
+ captured_at timestamp with time zone NOT NULL,
+ cpu_pct_avg double precision DEFAULT 0 NOT NULL,
+ cpu_pct_max double precision DEFAULT 0 NOT NULL,
+ mem_pct_avg double precision DEFAULT 0 NOT NULL,
+ mem_pct_max double precision DEFAULT 0 NOT NULL,
+ disk_pct_avg double precision DEFAULT 0 NOT NULL,
+ disk_pct_max double precision DEFAULT 0 NOT NULL,
+ load_1m double precision DEFAULT 0 NOT NULL,
+ load_5m double precision DEFAULT 0 NOT NULL,
+ load_15m double precision DEFAULT 0 NOT NULL,
+ connections_avg integer DEFAULT 0 NOT NULL,
+ connections_max integer DEFAULT 0 NOT NULL,
+ connections_me_avg integer DEFAULT 0 NOT NULL,
+ connections_direct_avg integer DEFAULT 0 NOT NULL,
+ active_users_avg integer DEFAULT 0 NOT NULL,
+ active_users_max integer DEFAULT 0 NOT NULL,
+ connections_total bigint DEFAULT 0 NOT NULL,
+ connections_bad_total bigint DEFAULT 0 NOT NULL,
+ handshake_timeouts_total bigint DEFAULT 0 NOT NULL,
+ dc_coverage_min_pct double precision DEFAULT 0 NOT NULL,
+ dc_coverage_avg_pct double precision DEFAULT 0 NOT NULL,
+ healthy_upstreams integer DEFAULT 0 NOT NULL,
+ total_upstreams integer DEFAULT 0 NOT NULL,
+ net_bytes_sent bigint DEFAULT 0 NOT NULL,
+ net_bytes_recv bigint DEFAULT 0 NOT NULL,
+ sample_count integer DEFAULT 1 NOT NULL
+);
+
+CREATE TABLE public.ts_server_load_hourly (
+ agent_id text NOT NULL,
+ bucket_hour timestamp with time zone NOT NULL,
+ cpu_pct_avg real,
+ cpu_pct_max real,
+ mem_pct_avg real,
+ mem_pct_max real,
+ connections_avg real,
+ connections_max integer,
+ active_users_avg real,
+ active_users_max integer,
+ dc_coverage_min real,
+ dc_coverage_avg real,
+ sample_count integer DEFAULT 0 NOT NULL
+);
+
+CREATE TABLE public.update_config (
+ key text NOT NULL,
+ value text NOT NULL
+);
+
+CREATE TABLE public.user_appearance (
+ user_id text NOT NULL,
+ theme text DEFAULT 'system'::text NOT NULL,
+ density text DEFAULT 'comfortable'::text NOT NULL,
+ help_mode text DEFAULT 'basic'::text NOT NULL,
+ updated_at timestamp with time zone DEFAULT '1970-01-01 00:00:00+00'::timestamp with time zone NOT NULL
+);
+
+CREATE TABLE public.user_fleet_group_scopes (
+ user_id text NOT NULL,
+ fleet_group_id uuid NOT NULL,
+ granted_at timestamp with time zone DEFAULT now() NOT NULL,
+ granted_by text DEFAULT ''::text NOT NULL
+);
+
+CREATE TABLE public.users (
+ id text NOT NULL,
+ username text NOT NULL,
+ password_hash text NOT NULL,
+ role text NOT NULL,
+ totp_enabled boolean DEFAULT false NOT NULL,
+ totp_secret text DEFAULT ''::text NOT NULL,
+ created_at timestamp with time zone NOT NULL
+);
+
+CREATE TABLE public.webhook_endpoints (
+ id text NOT NULL,
+ name text NOT NULL,
+ url text NOT NULL,
+ secret_ciphertext text NOT NULL,
+ event_filter text DEFAULT ''::text NOT NULL,
+ allow_private boolean DEFAULT false NOT NULL,
+ enabled boolean DEFAULT true NOT NULL,
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
+ updated_at timestamp with time zone DEFAULT now() NOT NULL
+);
+
+CREATE TABLE public.webhook_outbox (
+ id text NOT NULL,
+ endpoint_id text NOT NULL,
+ event_action text NOT NULL,
+ payload jsonb NOT NULL,
+ attempt integer DEFAULT 0 NOT NULL,
+ next_attempt_at timestamp with time zone NOT NULL,
+ last_error text DEFAULT ''::text NOT NULL,
+ dead boolean DEFAULT false NOT NULL,
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
+ delivered_at timestamp with time zone
+);
+
+ALTER TABLE ONLY public.enrollment_events ALTER COLUMN id SET DEFAULT nextval('public.enrollment_events_id_seq'::regclass);
+
+ALTER TABLE ONLY public.agent_certificate_recovery_grants
+ ADD CONSTRAINT agent_certificate_recovery_grants_pkey PRIMARY KEY (agent_id);
+
+ALTER TABLE ONLY public.agent_config_targets
+ ADD CONSTRAINT agent_config_targets_pkey PRIMARY KEY (scope_type, scope_id);
+
+ALTER TABLE ONLY public.agent_fallback_state
+ ADD CONSTRAINT agent_fallback_state_pkey PRIMARY KEY (agent_id);
+
+ALTER TABLE ONLY public.agent_revocations
+ ADD CONSTRAINT agent_revocations_pkey PRIMARY KEY (agent_id);
+
+ALTER TABLE ONLY public.agents
+ ADD CONSTRAINT agents_pkey PRIMARY KEY (id);
+
+ALTER TABLE ONLY public.audit_events
+ ADD CONSTRAINT audit_events_pkey PRIMARY KEY (id);
+
+ALTER TABLE ONLY public.certificate_authority
+ ADD CONSTRAINT certificate_authority_pkey PRIMARY KEY (scope);
+
+ALTER TABLE ONLY public.client_assignments
+ ADD CONSTRAINT client_assignments_pkey PRIMARY KEY (id);
+
+ALTER TABLE ONLY public.client_deployments
+ ADD CONSTRAINT client_deployments_pkey PRIMARY KEY (client_id, agent_id);
+
+ALTER TABLE ONLY public.client_ip_history
+ ADD CONSTRAINT client_ip_history_pkey PRIMARY KEY (agent_id, client_id, ip_address);
+
+ALTER TABLE ONLY public.client_usage
+ ADD CONSTRAINT client_usage_pkey PRIMARY KEY (client_id, agent_id);
+
+ALTER TABLE ONLY public.clients
+ ADD CONSTRAINT clients_pkey PRIMARY KEY (id);
+
+ALTER TABLE ONLY public.config_apply_batch_targets
+ ADD CONSTRAINT config_apply_batch_targets_pkey PRIMARY KEY (batch_id, agent_id);
+
+ALTER TABLE ONLY public.config_apply_batches
+ ADD CONSTRAINT config_apply_batches_pkey PRIMARY KEY (id);
+
+ALTER TABLE ONLY public.consumed_totp
+ ADD CONSTRAINT consumed_totp_pkey PRIMARY KEY (user_id, code);
+
+ALTER TABLE ONLY public.cp_secrets
+ ADD CONSTRAINT cp_secrets_pkey PRIMARY KEY (key);
+
+ALTER TABLE ONLY public.discovered_clients
+ ADD CONSTRAINT discovered_clients_agent_id_client_name_key UNIQUE (agent_id, client_name);
+
+ALTER TABLE ONLY public.discovered_clients
+ ADD CONSTRAINT discovered_clients_pkey PRIMARY KEY (id);
+
+ALTER TABLE ONLY public.enrollment_attempts
+ ADD CONSTRAINT enrollment_attempts_pkey PRIMARY KEY (id);
+
+ALTER TABLE ONLY public.enrollment_events
+ ADD CONSTRAINT enrollment_events_pkey PRIMARY KEY (id);
+
+ALTER TABLE ONLY public.enrollment_tokens
+ ADD CONSTRAINT enrollment_tokens_pkey PRIMARY KEY (value);
+
+ALTER TABLE ONLY public.fleet_group_integrations
+ ADD CONSTRAINT fleet_group_integrations_fleet_group_id_kind_key UNIQUE (fleet_group_id, kind);
+
+ALTER TABLE ONLY public.fleet_group_integrations
+ ADD CONSTRAINT fleet_group_integrations_pkey PRIMARY KEY (id);
+
+ALTER TABLE ONLY public.fleet_groups
+ ADD CONSTRAINT fleet_groups_name_unique UNIQUE (name);
+
+ALTER TABLE ONLY public.fleet_groups
+ ADD CONSTRAINT fleet_groups_pkey PRIMARY KEY (id);
+
+ALTER TABLE ONLY public.integration_providers
+ ADD CONSTRAINT integration_providers_pkey PRIMARY KEY (id);
+
+ALTER TABLE ONLY public.job_targets
+ ADD CONSTRAINT job_targets_pkey PRIMARY KEY (job_id, agent_id);
+
+ALTER TABLE ONLY public.jobs
+ ADD CONSTRAINT jobs_idempotency_key_key UNIQUE (idempotency_key);
+
+ALTER TABLE ONLY public.jobs
+ ADD CONSTRAINT jobs_pkey PRIMARY KEY (id);
+
+ALTER TABLE ONLY public.login_lockouts
+ ADD CONSTRAINT login_lockouts_pkey PRIMARY KEY (username);
+
+ALTER TABLE ONLY public.metric_snapshots
+ ADD CONSTRAINT metric_snapshots_pkey PRIMARY KEY (id);
+
+ALTER TABLE ONLY public.panel_settings
+ ADD CONSTRAINT panel_settings_pkey PRIMARY KEY (scope);
+
+ALTER TABLE ONLY public.runtime_settings
+ ADD CONSTRAINT runtime_settings_pkey PRIMARY KEY (name);
+
+ALTER TABLE ONLY public.sessions
+ ADD CONSTRAINT sessions_pkey PRIMARY KEY (id);
+
+ALTER TABLE ONLY public.telemt_diagnostics_current
+ ADD CONSTRAINT telemt_diagnostics_current_pkey PRIMARY KEY (agent_id);
+
+ALTER TABLE ONLY public.telemt_instances
+ ADD CONSTRAINT telemt_instances_pkey PRIMARY KEY (id);
+
+ALTER TABLE ONLY public.telemt_runtime_current
+ ADD CONSTRAINT telemt_runtime_current_pkey PRIMARY KEY (agent_id);
+
+ALTER TABLE ONLY public.telemt_runtime_dcs_current
+ ADD CONSTRAINT telemt_runtime_dcs_current_pkey PRIMARY KEY (agent_id, dc);
+
+ALTER TABLE ONLY public.telemt_runtime_events
+ ADD CONSTRAINT telemt_runtime_events_pkey PRIMARY KEY (agent_id, sequence);
+
+ALTER TABLE ONLY public.telemt_runtime_upstreams_current
+ ADD CONSTRAINT telemt_runtime_upstreams_current_pkey PRIMARY KEY (agent_id, upstream_id);
+
+ALTER TABLE ONLY public.telemt_security_inventory_current
+ ADD CONSTRAINT telemt_security_inventory_current_pkey PRIMARY KEY (agent_id);
+
+ALTER TABLE ONLY public.ts_dc_health
+ ADD CONSTRAINT ts_dc_health_pkey PRIMARY KEY (agent_id, dc, captured_at);
+
+ALTER TABLE ONLY public.ts_server_load_hourly
+ ADD CONSTRAINT ts_server_load_hourly_pkey PRIMARY KEY (agent_id, bucket_hour);
+
+ALTER TABLE ONLY public.ts_server_load
+ ADD CONSTRAINT ts_server_load_pkey PRIMARY KEY (agent_id, captured_at);
+
+ALTER TABLE ONLY public.update_config
+ ADD CONSTRAINT update_config_pkey PRIMARY KEY (key);
+
+ALTER TABLE ONLY public.user_appearance
+ ADD CONSTRAINT user_appearance_pkey PRIMARY KEY (user_id);
+
+ALTER TABLE ONLY public.user_fleet_group_scopes
+ ADD CONSTRAINT user_fleet_group_scopes_pkey PRIMARY KEY (user_id, fleet_group_id);
+
+ALTER TABLE ONLY public.users
+ ADD CONSTRAINT users_pkey PRIMARY KEY (id);
+
+ALTER TABLE ONLY public.users
+ ADD CONSTRAINT users_username_key UNIQUE (username);
+
+ALTER TABLE ONLY public.webhook_endpoints
+ ADD CONSTRAINT webhook_endpoints_name_key UNIQUE (name);
+
+ALTER TABLE ONLY public.webhook_endpoints
+ ADD CONSTRAINT webhook_endpoints_pkey PRIMARY KEY (id);
+
+ALTER TABLE ONLY public.webhook_outbox
+ ADD CONSTRAINT webhook_outbox_pkey PRIMARY KEY (id);
+
+CREATE UNIQUE INDEX clients_subscription_token_key ON public.clients USING btree (subscription_token) WHERE (subscription_token IS NOT NULL);
+
+CREATE INDEX idx_agent_fallback_state_entered_at ON public.agent_fallback_state USING btree (entered_at_unix);
+
+CREATE INDEX idx_agent_revocations_cert_expires_at ON public.agent_revocations USING btree (cert_expires_at);
+
+CREATE INDEX idx_agents_cert_spki_sha256 ON public.agents USING btree (cert_spki_sha256) WHERE (length(cert_spki_sha256) > 0);
+
+CREATE INDEX idx_agents_fleet_group_id ON public.agents USING btree (fleet_group_id);
+
+CREATE INDEX idx_agents_last_seen_at ON public.agents USING btree (last_seen_at);
+
+CREATE INDEX idx_agents_transport_mode ON public.agents USING btree (transport_mode);
+
+CREATE INDEX idx_audit_events_chain_walk ON public.audit_events USING btree (created_at, id);
+
+CREATE INDEX idx_audit_events_created_at ON public.audit_events USING btree (created_at);
+
+CREATE INDEX idx_client_assignments_client_id ON public.client_assignments USING btree (client_id);
+
+CREATE INDEX idx_client_deployments_client_id ON public.client_deployments USING btree (client_id);
+
+CREATE INDEX idx_client_ip_client ON public.client_ip_history USING btree (client_id, last_seen DESC);
+
+CREATE INDEX idx_client_ip_client_addr ON public.client_ip_history USING btree (client_id, ip_address);
+
+CREATE INDEX idx_client_ip_last_seen ON public.client_ip_history USING btree (last_seen);
+
+CREATE INDEX idx_client_usage_agent_id ON public.client_usage USING btree (agent_id);
+
+CREATE INDEX idx_config_apply_batch_targets_batch_wave ON public.config_apply_batch_targets USING btree (batch_id, wave_index);
+
+CREATE INDEX idx_config_apply_batches_status ON public.config_apply_batches USING btree (status);
+
+CREATE INDEX idx_consumed_totp_used_at ON public.consumed_totp USING btree (used_at);
+
+CREATE INDEX idx_discovered_clients_agent_id ON public.discovered_clients USING btree (agent_id);
+
+CREATE UNIQUE INDEX idx_discovered_clients_pending_unique ON public.discovered_clients USING btree (agent_id, client_name) WHERE (status = 'pending_review'::text);
+
+CREATE INDEX idx_enrollment_attempts_agent ON public.enrollment_attempts USING btree (agent_id);
+
+CREATE INDEX idx_enrollment_attempts_started ON public.enrollment_attempts USING btree (started_at);
+
+CREATE INDEX idx_enrollment_attempts_token ON public.enrollment_attempts USING btree (token_id);
+
+CREATE INDEX idx_enrollment_events_attempt ON public.enrollment_events USING btree (attempt_id, ts);
+
+CREATE INDEX idx_enrollment_tokens_fleet_group_id ON public.enrollment_tokens USING btree (fleet_group_id);
+
+CREATE INDEX idx_fleet_group_integrations_fleet_group_id ON public.fleet_group_integrations USING btree (fleet_group_id);
+
+CREATE INDEX idx_fleet_group_integrations_kind ON public.fleet_group_integrations USING btree (kind);
+
+CREATE INDEX idx_integration_providers_kind ON public.integration_providers USING btree (kind);
+
+CREATE INDEX idx_job_targets_agent_id ON public.job_targets USING btree (agent_id);
+
+CREATE INDEX idx_jobs_actor_id ON public.jobs USING btree (actor_id);
+
+CREATE INDEX idx_jobs_created_at ON public.jobs USING btree (created_at);
+
+CREATE INDEX idx_jobs_status ON public.jobs USING btree (status);
+
+CREATE INDEX idx_login_lockouts_locked_at ON public.login_lockouts USING btree (locked_at);
+
+CREATE INDEX idx_metric_snapshots_agent_captured ON public.metric_snapshots USING btree (agent_id, captured_at);
+
+CREATE INDEX idx_metric_snapshots_captured_at ON public.metric_snapshots USING btree (captured_at);
+
+CREATE INDEX idx_sessions_created_at ON public.sessions USING btree (created_at);
+
+CREATE INDEX idx_sessions_user_id ON public.sessions USING btree (user_id);
+
+CREATE INDEX idx_telemt_instances_agent_id ON public.telemt_instances USING btree (agent_id);
+
+CREATE INDEX idx_ts_dc_health_time ON public.ts_dc_health USING btree (agent_id, captured_at DESC);
+
+CREATE INDEX idx_ts_server_load_time ON public.ts_server_load USING btree (agent_id, captured_at DESC);
+
+CREATE INDEX idx_user_fleet_group_scopes_fleet_group_id ON public.user_fleet_group_scopes USING btree (fleet_group_id);
+
+CREATE INDEX idx_user_fleet_group_scopes_user_id ON public.user_fleet_group_scopes USING btree (user_id);
+
+CREATE INDEX idx_webhook_outbox_ready ON public.webhook_outbox USING btree (next_attempt_at) WHERE ((dead = false) AND (delivered_at IS NULL));
+
+ALTER TABLE ONLY public.agent_certificate_recovery_grants
+ ADD CONSTRAINT agent_certificate_recovery_grants_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES public.agents(id) ON DELETE CASCADE;
+
+ALTER TABLE ONLY public.agent_fallback_state
+ ADD CONSTRAINT agent_fallback_state_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES public.agents(id) ON DELETE CASCADE;
+
+ALTER TABLE ONLY public.agents
+ ADD CONSTRAINT agents_fleet_group_id_fkey FOREIGN KEY (fleet_group_id) REFERENCES public.fleet_groups(id);
+
+ALTER TABLE ONLY public.client_assignments
+ ADD CONSTRAINT client_assignments_client_id_fkey FOREIGN KEY (client_id) REFERENCES public.clients(id) ON DELETE CASCADE;
+
+ALTER TABLE ONLY public.client_deployments
+ ADD CONSTRAINT client_deployments_client_id_fkey FOREIGN KEY (client_id) REFERENCES public.clients(id) ON DELETE CASCADE;
+
+ALTER TABLE ONLY public.client_usage
+ ADD CONSTRAINT client_usage_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES public.agents(id) ON DELETE CASCADE;
+
+ALTER TABLE ONLY public.client_usage
+ ADD CONSTRAINT client_usage_client_id_fkey FOREIGN KEY (client_id) REFERENCES public.clients(id) ON DELETE CASCADE;
+
+ALTER TABLE ONLY public.config_apply_batch_targets
+ ADD CONSTRAINT config_apply_batch_targets_batch_id_fkey FOREIGN KEY (batch_id) REFERENCES public.config_apply_batches(id) ON DELETE CASCADE;
+
+ALTER TABLE ONLY public.config_apply_batches
+ ADD CONSTRAINT config_apply_batches_fleet_group_id_fkey FOREIGN KEY (fleet_group_id) REFERENCES public.fleet_groups(id) ON DELETE CASCADE;
+
+ALTER TABLE ONLY public.discovered_clients
+ ADD CONSTRAINT discovered_clients_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES public.agents(id) ON DELETE CASCADE;
+
+ALTER TABLE ONLY public.enrollment_events
+ ADD CONSTRAINT enrollment_events_attempt_id_fkey FOREIGN KEY (attempt_id) REFERENCES public.enrollment_attempts(id) ON DELETE CASCADE;
+
+ALTER TABLE ONLY public.enrollment_tokens
+ ADD CONSTRAINT enrollment_tokens_fleet_group_id_fkey FOREIGN KEY (fleet_group_id) REFERENCES public.fleet_groups(id) ON DELETE SET NULL;
+
+ALTER TABLE ONLY public.client_assignments
+ ADD CONSTRAINT fk_client_assignments_agent_id FOREIGN KEY (agent_id) REFERENCES public.agents(id) ON DELETE SET NULL;
+
+ALTER TABLE ONLY public.client_assignments
+ ADD CONSTRAINT fk_client_assignments_fleet_group_id FOREIGN KEY (fleet_group_id) REFERENCES public.fleet_groups(id) ON DELETE SET NULL;
+
+ALTER TABLE ONLY public.client_deployments
+ ADD CONSTRAINT fk_client_deployments_agent_id FOREIGN KEY (agent_id) REFERENCES public.agents(id) ON DELETE CASCADE;
+
+ALTER TABLE ONLY public.metric_snapshots
+ ADD CONSTRAINT fk_metric_snapshots_agent_id FOREIGN KEY (agent_id) REFERENCES public.agents(id) ON DELETE CASCADE;
+
+ALTER TABLE ONLY public.sessions
+ ADD CONSTRAINT fk_sessions_user_id FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE;
+
+ALTER TABLE ONLY public.fleet_group_integrations
+ ADD CONSTRAINT fleet_group_integrations_fleet_group_id_fkey FOREIGN KEY (fleet_group_id) REFERENCES public.fleet_groups(id) ON DELETE CASCADE;
+
+ALTER TABLE ONLY public.fleet_group_integrations
+ ADD CONSTRAINT fleet_group_integrations_provider_id_fkey FOREIGN KEY (provider_id) REFERENCES public.integration_providers(id) ON DELETE SET NULL;
+
+ALTER TABLE ONLY public.job_targets
+ ADD CONSTRAINT job_targets_job_id_fkey FOREIGN KEY (job_id) REFERENCES public.jobs(id);
+
+ALTER TABLE ONLY public.telemt_diagnostics_current
+ ADD CONSTRAINT telemt_diagnostics_current_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES public.agents(id) ON DELETE CASCADE;
+
+ALTER TABLE ONLY public.telemt_instances
+ ADD CONSTRAINT telemt_instances_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES public.agents(id) ON DELETE CASCADE;
+
+ALTER TABLE ONLY public.telemt_runtime_current
+ ADD CONSTRAINT telemt_runtime_current_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES public.agents(id) ON DELETE CASCADE;
+
+ALTER TABLE ONLY public.telemt_runtime_dcs_current
+ ADD CONSTRAINT telemt_runtime_dcs_current_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES public.agents(id) ON DELETE CASCADE;
+
+ALTER TABLE ONLY public.telemt_runtime_events
+ ADD CONSTRAINT telemt_runtime_events_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES public.agents(id) ON DELETE CASCADE;
+
+ALTER TABLE ONLY public.telemt_runtime_upstreams_current
+ ADD CONSTRAINT telemt_runtime_upstreams_current_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES public.agents(id) ON DELETE CASCADE;
+
+ALTER TABLE ONLY public.telemt_security_inventory_current
+ ADD CONSTRAINT telemt_security_inventory_current_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES public.agents(id) ON DELETE CASCADE;
+
+ALTER TABLE ONLY public.user_appearance
+ ADD CONSTRAINT user_appearance_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE;
+
+ALTER TABLE ONLY public.user_fleet_group_scopes
+ ADD CONSTRAINT user_fleet_group_scopes_fleet_group_id_fkey FOREIGN KEY (fleet_group_id) REFERENCES public.fleet_groups(id) ON DELETE CASCADE;
+
+ALTER TABLE ONLY public.user_fleet_group_scopes
+ ADD CONSTRAINT user_fleet_group_scopes_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE;
+
+ALTER TABLE ONLY public.webhook_outbox
+ ADD CONSTRAINT webhook_outbox_endpoint_id_fkey FOREIGN KEY (endpoint_id) REFERENCES public.webhook_endpoints(id) ON DELETE CASCADE;
+
+
-- +goose Down
-DROP TABLE IF EXISTS certificate_authority;
-DROP TABLE IF EXISTS panel_settings;
-DROP TABLE IF EXISTS client_deployments;
-DROP TABLE IF EXISTS client_assignments;
-DROP TABLE IF EXISTS clients;
-DROP TABLE IF EXISTS agent_certificate_recovery_grants;
-DROP TABLE IF EXISTS enrollment_tokens;
-DROP TABLE IF EXISTS metric_snapshots;
-DROP TABLE IF EXISTS audit_events;
-DROP TABLE IF EXISTS job_targets;
-DROP TABLE IF EXISTS jobs;
-DROP TABLE IF EXISTS telemt_detail_boosts;
-DROP TABLE IF EXISTS telemt_security_inventory_current;
-DROP TABLE IF EXISTS telemt_diagnostics_current;
-DROP TABLE IF EXISTS telemt_runtime_events;
-DROP TABLE IF EXISTS telemt_runtime_upstreams_current;
-DROP TABLE IF EXISTS telemt_runtime_dcs_current;
-DROP TABLE IF EXISTS telemt_runtime_current;
-DROP TABLE IF EXISTS telemt_instances;
-DROP TABLE IF EXISTS agents;
-DROP TABLE IF EXISTS user_appearance;
-DROP TABLE IF EXISTS users;
-DROP TABLE IF EXISTS fleet_groups;
+-- Squash-init: даунгрейда некуда — no-op.
+SELECT 1;
diff --git a/db/migrations/postgres/0002_discovered_clients.sql b/db/migrations/postgres/0002_discovered_clients.sql
deleted file mode 100644
index 55397d8a..00000000
--- a/db/migrations/postgres/0002_discovered_clients.sql
+++ /dev/null
@@ -1,22 +0,0 @@
--- +goose Up
-CREATE TABLE IF NOT EXISTS discovered_clients (
- id TEXT PRIMARY KEY,
- agent_id TEXT NOT NULL REFERENCES agents (id),
- client_name TEXT NOT NULL,
- secret TEXT NOT NULL DEFAULT '',
- status TEXT NOT NULL DEFAULT 'pending_review',
- total_octets BIGINT NOT NULL DEFAULT 0,
- current_connections INTEGER NOT NULL DEFAULT 0,
- active_unique_ips INTEGER NOT NULL DEFAULT 0,
- connection_link TEXT NOT NULL DEFAULT '',
- max_tcp_conns INTEGER NOT NULL DEFAULT 0,
- max_unique_ips INTEGER NOT NULL DEFAULT 0,
- data_quota_bytes BIGINT NOT NULL DEFAULT 0,
- expiration TEXT NOT NULL DEFAULT '',
- discovered_at TIMESTAMPTZ NOT NULL,
- updated_at TIMESTAMPTZ NOT NULL,
- UNIQUE (agent_id, client_name)
-);
-
--- +goose Down
-DROP TABLE IF EXISTS discovered_clients;
diff --git a/db/migrations/postgres/0003_agent_cert_dates.sql b/db/migrations/postgres/0003_agent_cert_dates.sql
deleted file mode 100644
index 414cafb2..00000000
--- a/db/migrations/postgres/0003_agent_cert_dates.sql
+++ /dev/null
@@ -1,7 +0,0 @@
--- +goose Up
-ALTER TABLE agents ADD COLUMN IF NOT EXISTS cert_issued_at TIMESTAMPTZ;
-ALTER TABLE agents ADD COLUMN IF NOT EXISTS cert_expires_at TIMESTAMPTZ;
-
--- +goose Down
-ALTER TABLE agents DROP COLUMN IF EXISTS cert_expires_at;
-ALTER TABLE agents DROP COLUMN IF EXISTS cert_issued_at;
diff --git a/db/migrations/postgres/0004_sessions.sql b/db/migrations/postgres/0004_sessions.sql
deleted file mode 100644
index 7b8ece42..00000000
--- a/db/migrations/postgres/0004_sessions.sql
+++ /dev/null
@@ -1,14 +0,0 @@
--- +goose Up
-CREATE TABLE IF NOT EXISTS sessions (
- id TEXT PRIMARY KEY,
- user_id TEXT NOT NULL,
- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
-);
-
-CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
-CREATE INDEX IF NOT EXISTS idx_sessions_created_at ON sessions(created_at);
-
--- +goose Down
-DROP INDEX IF EXISTS idx_sessions_created_at;
-DROP INDEX IF EXISTS idx_sessions_user_id;
-DROP TABLE IF EXISTS sessions;
diff --git a/db/migrations/postgres/0005_agent_revocations.sql b/db/migrations/postgres/0005_agent_revocations.sql
deleted file mode 100644
index b10233c0..00000000
--- a/db/migrations/postgres/0005_agent_revocations.sql
+++ /dev/null
@@ -1,21 +0,0 @@
--- +goose Up
--- P1-SEC-06: persist the set of deregistered agent IDs so that a control-plane
--- restart cannot "forget" a revocation. Without this table, an agent whose
--- admin deleted it but whose 30-day mTLS client cert is still valid could
--- reconnect and be accepted until cert expiry.
---
--- Cleanup removes rows once the cert has expired (cert_expires_at < now):
--- at that point the certificate can no longer authenticate regardless of
--- whether the agent_id is on the revocation list.
-CREATE TABLE IF NOT EXISTS agent_revocations (
- agent_id TEXT PRIMARY KEY,
- revoked_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
- cert_expires_at TIMESTAMPTZ NOT NULL
-);
-
-CREATE INDEX IF NOT EXISTS idx_agent_revocations_cert_expires_at
- ON agent_revocations(cert_expires_at);
-
--- +goose Down
-DROP INDEX IF EXISTS idx_agent_revocations_cert_expires_at;
-DROP TABLE IF EXISTS agent_revocations;
diff --git a/db/migrations/postgres/0006_timeseries_and_update_config.sql b/db/migrations/postgres/0006_timeseries_and_update_config.sql
deleted file mode 100644
index c0904b16..00000000
--- a/db/migrations/postgres/0006_timeseries_and_update_config.sql
+++ /dev/null
@@ -1,86 +0,0 @@
--- +goose Up
--- update_config stores internal feature-flag/kv state for the updater subsystem.
-CREATE TABLE IF NOT EXISTS update_config (
- key TEXT PRIMARY KEY,
- value TEXT NOT NULL
-);
-
--- Per-sample aggregated telemt server load timeseries (minute granularity).
-CREATE TABLE IF NOT EXISTS ts_server_load (
- agent_id TEXT NOT NULL,
- captured_at TIMESTAMPTZ NOT NULL,
- cpu_pct_avg REAL NOT NULL DEFAULT 0,
- cpu_pct_max REAL NOT NULL DEFAULT 0,
- mem_pct_avg REAL NOT NULL DEFAULT 0,
- mem_pct_max REAL NOT NULL DEFAULT 0,
- disk_pct_avg REAL NOT NULL DEFAULT 0,
- disk_pct_max REAL NOT NULL DEFAULT 0,
- load_1m REAL NOT NULL DEFAULT 0,
- load_5m REAL NOT NULL DEFAULT 0,
- load_15m REAL NOT NULL DEFAULT 0,
- connections_avg INTEGER NOT NULL DEFAULT 0,
- connections_max INTEGER NOT NULL DEFAULT 0,
- connections_me_avg INTEGER NOT NULL DEFAULT 0,
- connections_direct_avg INTEGER NOT NULL DEFAULT 0,
- active_users_avg INTEGER NOT NULL DEFAULT 0,
- active_users_max INTEGER NOT NULL DEFAULT 0,
- connections_total BIGINT NOT NULL DEFAULT 0,
- connections_bad_total BIGINT NOT NULL DEFAULT 0,
- handshake_timeouts_total BIGINT NOT NULL DEFAULT 0,
- dc_coverage_min_pct REAL NOT NULL DEFAULT 0,
- dc_coverage_avg_pct REAL NOT NULL DEFAULT 0,
- healthy_upstreams INTEGER NOT NULL DEFAULT 0,
- total_upstreams INTEGER NOT NULL DEFAULT 0,
- net_bytes_sent BIGINT NOT NULL DEFAULT 0,
- net_bytes_recv BIGINT NOT NULL DEFAULT 0,
- sample_count INTEGER NOT NULL DEFAULT 1,
- PRIMARY KEY (agent_id, captured_at)
-);
-
-CREATE TABLE IF NOT EXISTS ts_dc_health (
- agent_id TEXT NOT NULL,
- captured_at TIMESTAMPTZ NOT NULL,
- dc INTEGER NOT NULL,
- coverage_pct_avg REAL NOT NULL DEFAULT 0,
- coverage_pct_min REAL NOT NULL DEFAULT 0,
- rtt_ms_avg REAL NOT NULL DEFAULT 0,
- rtt_ms_max REAL NOT NULL DEFAULT 0,
- alive_writers_min INTEGER NOT NULL DEFAULT 0,
- required_writers INTEGER NOT NULL DEFAULT 0,
- load_max INTEGER NOT NULL DEFAULT 0,
- sample_count INTEGER NOT NULL DEFAULT 1,
- PRIMARY KEY (agent_id, dc, captured_at)
-);
-
-CREATE TABLE IF NOT EXISTS ts_server_load_hourly (
- agent_id TEXT NOT NULL,
- bucket_hour TIMESTAMPTZ NOT NULL,
- cpu_pct_avg REAL,
- cpu_pct_max REAL,
- mem_pct_avg REAL,
- mem_pct_max REAL,
- connections_avg REAL,
- connections_max INTEGER,
- active_users_avg REAL,
- active_users_max INTEGER,
- dc_coverage_min REAL,
- dc_coverage_avg REAL,
- sample_count INTEGER NOT NULL DEFAULT 0,
- PRIMARY KEY (agent_id, bucket_hour)
-);
-
-CREATE TABLE IF NOT EXISTS client_ip_history (
- agent_id TEXT NOT NULL,
- client_id TEXT NOT NULL,
- ip_address TEXT NOT NULL,
- first_seen TIMESTAMPTZ NOT NULL,
- last_seen TIMESTAMPTZ NOT NULL,
- PRIMARY KEY (agent_id, client_id, ip_address)
-);
-
--- +goose Down
-DROP TABLE IF EXISTS client_ip_history;
-DROP TABLE IF EXISTS ts_server_load_hourly;
-DROP TABLE IF EXISTS ts_dc_health;
-DROP TABLE IF EXISTS ts_server_load;
-DROP TABLE IF EXISTS update_config;
diff --git a/db/migrations/postgres/0007_indexes.sql b/db/migrations/postgres/0007_indexes.sql
deleted file mode 100644
index 6d497ef8..00000000
--- a/db/migrations/postgres/0007_indexes.sql
+++ /dev/null
@@ -1,36 +0,0 @@
--- +goose NO TRANSACTION
--- These indexes target tables already populated in production, so each
--- CREATE INDEX must run CONCURRENTLY to avoid an ACCESS EXCLUSIVE lock
--- on the table for the duration of the build. CONCURRENTLY is illegal
--- inside a transaction, hence the NO TRANSACTION pragma above.
--- +goose Up
-CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_agents_last_seen_at ON agents (last_seen_at);
-CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_agents_fleet_group_id ON agents (fleet_group_id);
-CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_telemt_instances_agent_id ON telemt_instances (agent_id);
-CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_client_assignments_client_id ON client_assignments (client_id);
-CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_client_deployments_client_id ON client_deployments (client_id);
-CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_jobs_created_at ON jobs (created_at);
-CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_audit_events_created_at ON audit_events (created_at);
-CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_metric_snapshots_agent_captured ON metric_snapshots (agent_id, captured_at);
-CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_discovered_clients_agent_id ON discovered_clients (agent_id);
-CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_ts_server_load_time ON ts_server_load (agent_id, captured_at DESC);
-CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_ts_dc_health_time ON ts_dc_health (agent_id, captured_at DESC);
-CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_client_ip_last_seen ON client_ip_history (last_seen);
-CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_client_ip_client ON client_ip_history (client_id, last_seen DESC);
-CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_client_ip_client_addr ON client_ip_history (client_id, ip_address);
-
--- +goose Down
-DROP INDEX CONCURRENTLY IF EXISTS idx_client_ip_client_addr;
-DROP INDEX CONCURRENTLY IF EXISTS idx_client_ip_client;
-DROP INDEX CONCURRENTLY IF EXISTS idx_client_ip_last_seen;
-DROP INDEX CONCURRENTLY IF EXISTS idx_ts_dc_health_time;
-DROP INDEX CONCURRENTLY IF EXISTS idx_ts_server_load_time;
-DROP INDEX CONCURRENTLY IF EXISTS idx_discovered_clients_agent_id;
-DROP INDEX CONCURRENTLY IF EXISTS idx_metric_snapshots_agent_captured;
-DROP INDEX CONCURRENTLY IF EXISTS idx_audit_events_created_at;
-DROP INDEX CONCURRENTLY IF EXISTS idx_jobs_created_at;
-DROP INDEX CONCURRENTLY IF EXISTS idx_client_deployments_client_id;
-DROP INDEX CONCURRENTLY IF EXISTS idx_client_assignments_client_id;
-DROP INDEX CONCURRENTLY IF EXISTS idx_telemt_instances_agent_id;
-DROP INDEX CONCURRENTLY IF EXISTS idx_agents_fleet_group_id;
-DROP INDEX CONCURRENTLY IF EXISTS idx_agents_last_seen_at;
diff --git a/db/migrations/postgres/0008_missing_indexes.sql b/db/migrations/postgres/0008_missing_indexes.sql
deleted file mode 100644
index 6b85f81a..00000000
--- a/db/migrations/postgres/0008_missing_indexes.sql
+++ /dev/null
@@ -1,17 +0,0 @@
--- +goose NO TRANSACTION
--- These indexes target tables already populated in production
--- (jobs, job_targets, metric_snapshots, enrollment_tokens), so each
--- CREATE INDEX must run CONCURRENTLY to avoid an ACCESS EXCLUSIVE lock
--- on the table for the duration of the build. CONCURRENTLY is illegal
--- inside a transaction, hence the NO TRANSACTION pragma above.
--- +goose Up
-CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_jobs_status ON jobs (status);
-CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_job_targets_agent_id ON job_targets (agent_id);
-CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_metric_snapshots_captured_at ON metric_snapshots (captured_at);
-CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_enrollment_tokens_fleet_group_id ON enrollment_tokens (fleet_group_id);
-
--- +goose Down
-DROP INDEX CONCURRENTLY IF EXISTS idx_enrollment_tokens_fleet_group_id;
-DROP INDEX CONCURRENTLY IF EXISTS idx_metric_snapshots_captured_at;
-DROP INDEX CONCURRENTLY IF EXISTS idx_job_targets_agent_id;
-DROP INDEX CONCURRENTLY IF EXISTS idx_jobs_status;
diff --git a/db/migrations/postgres/0009_retention_settings.sql b/db/migrations/postgres/0009_retention_settings.sql
deleted file mode 100644
index 117970cd..00000000
--- a/db/migrations/postgres/0009_retention_settings.sql
+++ /dev/null
@@ -1,12 +0,0 @@
--- +goose Up
--- P2-REL-03: persist retention settings across restarts.
--- Adds a retention_json column to panel_settings so the singleton
--- scope='panel' row also carries the operator-managed retention
--- configuration as an opaque JSON blob. Empty string means "not
--- yet set" and causes the control-plane to fall back to defaults.
-ALTER TABLE panel_settings
- ADD COLUMN IF NOT EXISTS retention_json TEXT NOT NULL DEFAULT '';
-
--- +goose Down
-ALTER TABLE panel_settings
- DROP COLUMN IF EXISTS retention_json;
diff --git a/db/migrations/postgres/0010_discovered_clients_pending_unique.sql b/db/migrations/postgres/0010_discovered_clients_pending_unique.sql
deleted file mode 100644
index 326836c7..00000000
--- a/db/migrations/postgres/0010_discovered_clients_pending_unique.sql
+++ /dev/null
@@ -1,17 +0,0 @@
--- +goose NO TRANSACTION
--- +goose Up
--- P2-LOG-02 (L-10 / M-C4): belt-and-suspenders guard against duplicate
--- pending_review rows. The table already has UNIQUE (agent_id, client_name)
--- from 0002, but adding this partial UNIQUE index makes the dedupe intent
--- explicit and keeps the invariant intact even if the broader constraint
--- is ever loosened (e.g. to allow historical "ignored"/"adopted" copies).
---
--- discovered_clients is a populated table in production, so the build
--- runs CONCURRENTLY (and therefore outside any transaction) to avoid
--- locking writers for the duration.
-CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS idx_discovered_clients_pending_unique
- ON discovered_clients (agent_id, client_name)
- WHERE status = 'pending_review';
-
--- +goose Down
-DROP INDEX CONCURRENTLY IF EXISTS idx_discovered_clients_pending_unique;
diff --git a/db/migrations/postgres/0012_cascade_fk.sql b/db/migrations/postgres/0012_cascade_fk.sql
deleted file mode 100644
index 9b7c8773..00000000
--- a/db/migrations/postgres/0012_cascade_fk.sql
+++ /dev/null
@@ -1,89 +0,0 @@
--- +goose Up
--- P2-DB-03 (DF-24 / M-F11): add missing foreign-key constraints and ON DELETE
--- CASCADE semantics so deleting a parent row (user, client) does not leave
--- orphan rows behind (sessions, enrollment tokens, metric snapshots, client
--- assignments, client deployments).
---
--- Dev-stage policy (plan v4): we only operate on dev databases. Drop-and-
--- recreate is acceptable, which means we clean up obvious orphan rows before
--- ADD CONSTRAINT so the migration does not abort on legacy data.
-
--- Sessions: 0004_sessions.sql created the table without a FK. Clean orphan
--- sessions, then attach the CASCADE FK to users(id).
-DELETE FROM sessions
-WHERE user_id NOT IN (SELECT id FROM users);
-
-ALTER TABLE sessions
- DROP CONSTRAINT IF EXISTS fk_sessions_user_id;
-
-ALTER TABLE sessions
- ADD CONSTRAINT fk_sessions_user_id
- FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE;
-
--- Enrollment tokens: deliberately NOT linked to fleet_groups by a FK. The
--- control-plane issues tokens against a fleet group id that may not yet exist
--- — agent_flow.consumeEnrollmentToken lazily creates the group on first
--- enrollment. Adding a FK here would break that "issue-then-create" flow.
--- See internal/controlplane/server/agent_flow.go (consumeEnrollmentToken)
--- and P2-DB-03 discussion.
-
--- Metric snapshots: PG 0001_init already has a FK on agent_id but without
--- CASCADE. Replace with a CASCADE variant so DeleteAgent cleans up history.
-DELETE FROM metric_snapshots
-WHERE agent_id NOT IN (SELECT id FROM agents);
-
-ALTER TABLE metric_snapshots
- DROP CONSTRAINT IF EXISTS metric_snapshots_agent_id_fkey;
-ALTER TABLE metric_snapshots
- DROP CONSTRAINT IF EXISTS fk_metric_snapshots_agent_id;
-
-ALTER TABLE metric_snapshots
- ADD CONSTRAINT fk_metric_snapshots_agent_id
- FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE;
-
--- client_assignments.client_id already has ON DELETE CASCADE (from 0001_init).
--- The remaining FKs (fleet_group_id, agent_id) reference optional pointers and
--- should cascade-null when the target disappears.
-DELETE FROM client_assignments
-WHERE fleet_group_id IS NOT NULL
- AND fleet_group_id NOT IN (SELECT id FROM fleet_groups);
-
-DELETE FROM client_assignments
-WHERE agent_id IS NOT NULL
- AND agent_id NOT IN (SELECT id FROM agents);
-
-ALTER TABLE client_assignments
- DROP CONSTRAINT IF EXISTS client_assignments_fleet_group_id_fkey;
-ALTER TABLE client_assignments
- DROP CONSTRAINT IF EXISTS fk_client_assignments_fleet_group_id;
-
-ALTER TABLE client_assignments
- ADD CONSTRAINT fk_client_assignments_fleet_group_id
- FOREIGN KEY (fleet_group_id) REFERENCES fleet_groups (id) ON DELETE SET NULL;
-
-ALTER TABLE client_assignments
- DROP CONSTRAINT IF EXISTS client_assignments_agent_id_fkey;
-ALTER TABLE client_assignments
- DROP CONSTRAINT IF EXISTS fk_client_assignments_agent_id;
-
-ALTER TABLE client_assignments
- ADD CONSTRAINT fk_client_assignments_agent_id
- FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE SET NULL;
-
--- client_deployments.client_id already has ON DELETE CASCADE. Upgrade
--- client_deployments.agent_id to CASCADE so removing an agent prunes its rows.
-DELETE FROM client_deployments
-WHERE agent_id NOT IN (SELECT id FROM agents);
-
-ALTER TABLE client_deployments
- DROP CONSTRAINT IF EXISTS client_deployments_agent_id_fkey;
-ALTER TABLE client_deployments
- DROP CONSTRAINT IF EXISTS fk_client_deployments_agent_id;
-
-ALTER TABLE client_deployments
- ADD CONSTRAINT fk_client_deployments_agent_id
- FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE;
-
--- +goose Down
--- dev-stage: drop+recreate acceptable, no rollback.
-SELECT 1;
diff --git a/db/migrations/postgres/0013_login_lockout.sql b/db/migrations/postgres/0013_login_lockout.sql
deleted file mode 100644
index 919b7540..00000000
--- a/db/migrations/postgres/0013_login_lockout.sql
+++ /dev/null
@@ -1,20 +0,0 @@
--- +goose Up
--- S7: persist login-lockout state so a control-plane restart or
--- fail-over cannot be used to reset the failed-attempt counter for
--- an account. Key is the raw username as supplied by the client:
--- the auth flow already normalises it (strings.ToLower / TrimSpace),
--- and the DB-level PK makes upserts serializable without a
--- separate unique index.
-CREATE TABLE IF NOT EXISTS login_lockouts (
- username TEXT PRIMARY KEY,
- failures INTEGER NOT NULL DEFAULT 0,
- locked_at TIMESTAMPTZ,
- updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
-);
-
-CREATE INDEX IF NOT EXISTS idx_login_lockouts_locked_at
- ON login_lockouts(locked_at);
-
--- +goose Down
-DROP INDEX IF EXISTS idx_login_lockouts_locked_at;
-DROP TABLE IF EXISTS login_lockouts;
diff --git a/db/migrations/postgres/0014_fleet_groups_redesign.sql b/db/migrations/postgres/0014_fleet_groups_redesign.sql
deleted file mode 100644
index 3aa68479..00000000
--- a/db/migrations/postgres/0014_fleet_groups_redesign.sql
+++ /dev/null
@@ -1,118 +0,0 @@
--- +goose Up
--- Fleet groups redesign (breaking): `id` becomes UUID. The old schema
--- stored it as a semantic slug ("default", "edge") that cannot be
--- auto-cast; dependent FK rows are cleared so pre-release operators
--- re-enroll agents after upgrade. See sqlite/0014 for the equivalent
--- path on SQLite (where `id` remains TEXT and UUID format is
--- enforced at the application layer).
---
--- The display surface splits into `name` (immutable slug — unique) +
--- `label` (editable) + `description`. Adds an extensibility layer so
--- integrations (DNS round-robin, webhooks, future plugins) attach
--- per-group without schema churn every time a new kind ships.
-DELETE FROM client_assignments;
-DELETE FROM client_deployments;
-DELETE FROM discovered_clients;
-DELETE FROM enrollment_tokens;
-DELETE FROM telemt_runtime_events;
-DELETE FROM telemt_runtime_upstreams_current;
-DELETE FROM telemt_runtime_dcs_current;
-DELETE FROM telemt_runtime_current;
-DELETE FROM telemt_diagnostics_current;
-DELETE FROM telemt_security_inventory_current;
-DELETE FROM telemt_instances;
-DELETE FROM agents;
-DELETE FROM fleet_groups;
-
--- Drop FK constraints first: Postgres refuses to retype the parent PK
--- while child columns still reference it with a mismatched type, even
--- if the dependent tables were just truncated above.
-ALTER TABLE agents
- DROP CONSTRAINT IF EXISTS agents_fleet_group_id_fkey;
-ALTER TABLE enrollment_tokens
- DROP CONSTRAINT IF EXISTS enrollment_tokens_fleet_group_id_fkey;
-ALTER TABLE client_assignments
- DROP CONSTRAINT IF EXISTS client_assignments_fleet_group_id_fkey,
- DROP CONSTRAINT IF EXISTS fk_client_assignments_fleet_group_id;
-
-ALTER TABLE fleet_groups
- ALTER COLUMN id DROP DEFAULT,
- ALTER COLUMN id TYPE UUID USING gen_random_uuid(),
- ADD COLUMN label TEXT NOT NULL DEFAULT '',
- ADD COLUMN description TEXT NOT NULL DEFAULT '',
- ADD COLUMN updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
-
-ALTER TABLE fleet_groups
- ADD CONSTRAINT fleet_groups_name_unique UNIQUE (name);
-
--- Dependent FK columns → UUID, then reattach FKs.
-ALTER TABLE agents
- ALTER COLUMN fleet_group_id TYPE UUID USING NULL,
- ADD CONSTRAINT agents_fleet_group_id_fkey
- FOREIGN KEY (fleet_group_id) REFERENCES fleet_groups (id);
-
-ALTER TABLE enrollment_tokens
- ALTER COLUMN fleet_group_id TYPE UUID USING NULL,
- ADD CONSTRAINT enrollment_tokens_fleet_group_id_fkey
- FOREIGN KEY (fleet_group_id) REFERENCES fleet_groups (id);
-
-ALTER TABLE client_assignments
- ALTER COLUMN fleet_group_id TYPE UUID USING NULL,
- ADD CONSTRAINT fk_client_assignments_fleet_group_id
- FOREIGN KEY (fleet_group_id) REFERENCES fleet_groups (id) ON DELETE SET NULL;
-
--- Shared credential store so a single provider config (e.g. one
--- Cloudflare account) can back DNS integrations for many groups.
-CREATE TABLE IF NOT EXISTS integration_providers (
- id UUID PRIMARY KEY,
- kind TEXT NOT NULL,
- label TEXT NOT NULL DEFAULT '',
- config JSONB NOT NULL DEFAULT '{}'::jsonb,
- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
- updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
-);
-
-CREATE INDEX IF NOT EXISTS idx_integration_providers_kind
- ON integration_providers (kind);
-
--- Per-group integration install: at most one row per (group, kind).
--- provider_id is nullable because not every integration ships with a
--- shared credential — a local-only integration may embed its whole
--- config inline.
-CREATE TABLE IF NOT EXISTS fleet_group_integrations (
- id UUID PRIMARY KEY,
- fleet_group_id UUID NOT NULL REFERENCES fleet_groups (id) ON DELETE CASCADE,
- kind TEXT NOT NULL,
- provider_id UUID REFERENCES integration_providers (id) ON DELETE SET NULL,
- config JSONB NOT NULL DEFAULT '{}'::jsonb,
- enabled BOOLEAN NOT NULL DEFAULT FALSE,
- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
- updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
- UNIQUE (fleet_group_id, kind)
-);
-
-CREATE INDEX IF NOT EXISTS idx_fleet_group_integrations_fleet_group_id
- ON fleet_group_integrations (fleet_group_id);
-CREATE INDEX IF NOT EXISTS idx_fleet_group_integrations_kind
- ON fleet_group_integrations (kind);
-
--- +goose Down
-DROP INDEX IF EXISTS idx_fleet_group_integrations_kind;
-DROP INDEX IF EXISTS idx_fleet_group_integrations_fleet_group_id;
-DROP TABLE IF EXISTS fleet_group_integrations;
-DROP INDEX IF EXISTS idx_integration_providers_kind;
-DROP TABLE IF EXISTS integration_providers;
-
-ALTER TABLE client_assignments
- ALTER COLUMN fleet_group_id TYPE TEXT USING NULL;
-ALTER TABLE enrollment_tokens
- ALTER COLUMN fleet_group_id TYPE TEXT USING NULL;
-ALTER TABLE agents
- ALTER COLUMN fleet_group_id TYPE TEXT USING NULL;
-
-ALTER TABLE fleet_groups
- DROP CONSTRAINT IF EXISTS fleet_groups_name_unique,
- DROP COLUMN IF EXISTS updated_at,
- DROP COLUMN IF EXISTS description,
- DROP COLUMN IF EXISTS label,
- ALTER COLUMN id TYPE TEXT USING id::TEXT;
diff --git a/db/migrations/postgres/0015_client_usage.sql b/db/migrations/postgres/0015_client_usage.sql
deleted file mode 100644
index c5e380c7..00000000
--- a/db/migrations/postgres/0015_client_usage.sql
+++ /dev/null
@@ -1,29 +0,0 @@
--- +goose Up
--- Persist per-(client, agent) usage counters so they survive a panel
--- restart. Previously these lived only in server.clientUsage (an
--- in-memory map) and got re-seeded from discovered_clients.total_octets
--- as a stop-gap — fragile because that fallback depends on the agent
--- still being online and re-discovering the client.
---
--- The row is keyed by (client_id, agent_id) to match the in-memory
--- shape; aggregate totals across nodes are computed at read time.
--- last_seq carries the per-agent delta cursor (rewinds to 1 on agent
--- restart; the higher value wins) so dedupe works across CP restarts.
-CREATE TABLE IF NOT EXISTS client_usage (
- client_id TEXT NOT NULL REFERENCES clients (id) ON DELETE CASCADE,
- agent_id TEXT NOT NULL REFERENCES agents (id) ON DELETE CASCADE,
- traffic_used_bytes BIGINT NOT NULL DEFAULT 0,
- unique_ips_used INTEGER NOT NULL DEFAULT 0,
- active_tcp_conns INTEGER NOT NULL DEFAULT 0,
- active_unique_ips INTEGER NOT NULL DEFAULT 0,
- last_seq BIGINT NOT NULL DEFAULT 0,
- observed_at TIMESTAMPTZ NOT NULL,
- PRIMARY KEY (client_id, agent_id)
-);
-
-CREATE INDEX IF NOT EXISTS idx_client_usage_agent_id
- ON client_usage (agent_id);
-
--- +goose Down
-DROP INDEX IF EXISTS idx_client_usage_agent_id;
-DROP TABLE IF EXISTS client_usage;
diff --git a/db/migrations/postgres/0016_sessions_last_seen.sql b/db/migrations/postgres/0016_sessions_last_seen.sql
deleted file mode 100644
index 29946aa7..00000000
--- a/db/migrations/postgres/0016_sessions_last_seen.sql
+++ /dev/null
@@ -1,6 +0,0 @@
--- +goose Up
-ALTER TABLE sessions ADD COLUMN IF NOT EXISTS last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
-UPDATE sessions SET last_seen_at = created_at WHERE last_seen_at < created_at;
-
--- +goose Down
-ALTER TABLE sessions DROP COLUMN IF EXISTS last_seen_at;
diff --git a/db/migrations/postgres/0017_consumed_totp.sql b/db/migrations/postgres/0017_consumed_totp.sql
deleted file mode 100644
index 44cf061d..00000000
--- a/db/migrations/postgres/0017_consumed_totp.sql
+++ /dev/null
@@ -1,12 +0,0 @@
--- +goose Up
-CREATE TABLE IF NOT EXISTS consumed_totp (
- user_id TEXT NOT NULL,
- code TEXT NOT NULL,
- used_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
- PRIMARY KEY (user_id, code)
-);
-CREATE INDEX IF NOT EXISTS idx_consumed_totp_used_at ON consumed_totp(used_at);
-
--- +goose Down
-DROP INDEX IF EXISTS idx_consumed_totp_used_at;
-DROP TABLE IF EXISTS consumed_totp;
diff --git a/db/migrations/postgres/0018_cp_secrets.sql b/db/migrations/postgres/0018_cp_secrets.sql
deleted file mode 100644
index b1ac5bcc..00000000
--- a/db/migrations/postgres/0018_cp_secrets.sql
+++ /dev/null
@@ -1,9 +0,0 @@
--- +goose Up
-CREATE TABLE IF NOT EXISTS cp_secrets (
- key TEXT PRIMARY KEY,
- value BYTEA NOT NULL,
- updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
-);
-
--- +goose Down
-DROP TABLE IF EXISTS cp_secrets;
diff --git a/db/migrations/postgres/0019_jobs_actor_id_index.sql b/db/migrations/postgres/0019_jobs_actor_id_index.sql
deleted file mode 100644
index 7f2b75d3..00000000
--- a/db/migrations/postgres/0019_jobs_actor_id_index.sql
+++ /dev/null
@@ -1,7 +0,0 @@
--- +goose Up
--- +goose NO TRANSACTION
-CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_jobs_actor_id ON jobs(actor_id);
-
--- +goose Down
--- +goose NO TRANSACTION
-DROP INDEX CONCURRENTLY IF EXISTS idx_jobs_actor_id;
diff --git a/db/migrations/postgres/0020_agents_cert_serial.sql b/db/migrations/postgres/0020_agents_cert_serial.sql
deleted file mode 100644
index 507d0eb3..00000000
--- a/db/migrations/postgres/0020_agents_cert_serial.sql
+++ /dev/null
@@ -1,5 +0,0 @@
--- +goose Up
-ALTER TABLE agents ADD COLUMN IF NOT EXISTS cert_serial TEXT NOT NULL DEFAULT '';
-
--- +goose Down
-ALTER TABLE agents DROP COLUMN IF EXISTS cert_serial;
diff --git a/db/migrations/postgres/0021_enrollment_token_hash.sql b/db/migrations/postgres/0021_enrollment_token_hash.sql
deleted file mode 100644
index 09ff4766..00000000
--- a/db/migrations/postgres/0021_enrollment_token_hash.sql
+++ /dev/null
@@ -1,11 +0,0 @@
--- +goose Up
-ALTER TABLE enrollment_tokens ADD COLUMN IF NOT EXISTS value_hash TEXT NOT NULL DEFAULT '';
--- +goose StatementBegin
--- +goose NO TRANSACTION
-CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_enrollment_tokens_value_hash ON enrollment_tokens(value_hash);
--- +goose StatementEnd
-
--- +goose Down
--- +goose NO TRANSACTION
-DROP INDEX CONCURRENTLY IF EXISTS idx_enrollment_tokens_value_hash;
-ALTER TABLE enrollment_tokens DROP COLUMN IF EXISTS value_hash;
diff --git a/db/migrations/postgres/0022_cascade_fk_telemt_instances.sql b/db/migrations/postgres/0022_cascade_fk_telemt_instances.sql
deleted file mode 100644
index 95078408..00000000
--- a/db/migrations/postgres/0022_cascade_fk_telemt_instances.sql
+++ /dev/null
@@ -1,15 +0,0 @@
--- +goose Up
--- Q4.U-D-03: drop the legacy unconstrained FK and re-add it with
--- ON DELETE CASCADE so deleting an agent also drops its instances.
--- SQLite already has CASCADE here via 0012_cascade_fk.sql; this brings
--- postgres into parity.
-ALTER TABLE telemt_instances DROP CONSTRAINT IF EXISTS telemt_instances_agent_id_fkey;
-ALTER TABLE telemt_instances
- ADD CONSTRAINT telemt_instances_agent_id_fkey
- FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE;
-
--- +goose Down
-ALTER TABLE telemt_instances DROP CONSTRAINT IF EXISTS telemt_instances_agent_id_fkey;
-ALTER TABLE telemt_instances
- ADD CONSTRAINT telemt_instances_agent_id_fkey
- FOREIGN KEY (agent_id) REFERENCES agents (id);
diff --git a/db/migrations/postgres/0023_check_constraints.sql b/db/migrations/postgres/0023_check_constraints.sql
deleted file mode 100644
index ff16db47..00000000
--- a/db/migrations/postgres/0023_check_constraints.sql
+++ /dev/null
@@ -1,18 +0,0 @@
--- +goose Up
--- Q4.U-D-02: explicit CHECK constraints on enum-shaped TEXT columns.
--- These columns are already enums in the application — adding the
--- check at the DB level catches a stray writer (e.g. a future sqlc
--- query bug, an admin-issued UPDATE) before it corrupts the row.
-ALTER TABLE jobs ADD CONSTRAINT jobs_status_check
- CHECK (status IN ('queued','running','succeeded','failed','expired'));
-
-ALTER TABLE job_targets ADD CONSTRAINT job_targets_status_check
- CHECK (status IN ('queued','dispatched','acknowledged','succeeded','failed','expired'));
-
-ALTER TABLE discovered_clients ADD CONSTRAINT discovered_clients_status_check
- CHECK (status IN ('pending_review','adopted','ignored'));
-
--- +goose Down
-ALTER TABLE jobs DROP CONSTRAINT IF EXISTS jobs_status_check;
-ALTER TABLE job_targets DROP CONSTRAINT IF EXISTS job_targets_status_check;
-ALTER TABLE discovered_clients DROP CONSTRAINT IF EXISTS discovered_clients_status_check;
diff --git a/db/migrations/postgres/0024_unify_real_to_double.sql b/db/migrations/postgres/0024_unify_real_to_double.sql
deleted file mode 100644
index b74f51be..00000000
--- a/db/migrations/postgres/0024_unify_real_to_double.sql
+++ /dev/null
@@ -1,34 +0,0 @@
--- +goose Up
--- Q5.U-D-01: postgres REAL is 4-byte single precision; sqlite REAL is
--- 8-byte double precision. The drift means a counter that survives
--- 7 sig-digits round-trip on sqlite gets quantised to 4 bytes on
--- postgres. Promote every timeseries float column to DOUBLE PRECISION
--- so both backends carry the same numeric range. The migration is a
--- straight ALTER TYPE — postgres rewrites the table once, then both
--- drivers behave identically.
-ALTER TABLE ts_server_load
- ALTER COLUMN cpu_pct_avg TYPE DOUBLE PRECISION,
- ALTER COLUMN cpu_pct_max TYPE DOUBLE PRECISION,
- ALTER COLUMN mem_pct_avg TYPE DOUBLE PRECISION,
- ALTER COLUMN mem_pct_max TYPE DOUBLE PRECISION,
- ALTER COLUMN disk_pct_avg TYPE DOUBLE PRECISION,
- ALTER COLUMN disk_pct_max TYPE DOUBLE PRECISION,
- ALTER COLUMN load_1m TYPE DOUBLE PRECISION,
- ALTER COLUMN load_5m TYPE DOUBLE PRECISION,
- ALTER COLUMN load_15m TYPE DOUBLE PRECISION,
- ALTER COLUMN dc_coverage_min_pct TYPE DOUBLE PRECISION,
- ALTER COLUMN dc_coverage_avg_pct TYPE DOUBLE PRECISION;
-
--- +goose Down
-ALTER TABLE ts_server_load
- ALTER COLUMN cpu_pct_avg TYPE REAL,
- ALTER COLUMN cpu_pct_max TYPE REAL,
- ALTER COLUMN mem_pct_avg TYPE REAL,
- ALTER COLUMN mem_pct_max TYPE REAL,
- ALTER COLUMN disk_pct_avg TYPE REAL,
- ALTER COLUMN disk_pct_max TYPE REAL,
- ALTER COLUMN load_1m TYPE REAL,
- ALTER COLUMN load_5m TYPE REAL,
- ALTER COLUMN load_15m TYPE REAL,
- ALTER COLUMN dc_coverage_min_pct TYPE REAL,
- ALTER COLUMN dc_coverage_avg_pct TYPE REAL;
diff --git a/db/migrations/postgres/0025_user_fleet_group_scopes.sql b/db/migrations/postgres/0025_user_fleet_group_scopes.sql
deleted file mode 100644
index 8eb930cb..00000000
--- a/db/migrations/postgres/0025_user_fleet_group_scopes.sql
+++ /dev/null
@@ -1,25 +0,0 @@
--- +goose Up
--- R-S-14: per-user fleet-group scope mapping. An operator with at
--- least one row in this table is restricted to those fleet groups —
--- their /clients, /fleet-groups, /discovered-clients, and job-target
--- queries are filtered to the union of their scopes. An operator with
--- no rows here keeps the legacy global view (single-tenant default).
--- Admins are always global regardless of rows.
---
--- Cascade DELETE on both sides so removing a user or a fleet group
--- automatically tidies the join rows; no orphans, no manual cleanup.
-CREATE TABLE IF NOT EXISTS user_fleet_group_scopes (
- user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
- fleet_group_id UUID NOT NULL REFERENCES fleet_groups (id) ON DELETE CASCADE,
- granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
- granted_by TEXT NOT NULL DEFAULT '',
- PRIMARY KEY (user_id, fleet_group_id)
-);
-
-CREATE INDEX IF NOT EXISTS idx_user_fleet_group_scopes_user_id
- ON user_fleet_group_scopes (user_id);
-CREATE INDEX IF NOT EXISTS idx_user_fleet_group_scopes_fleet_group_id
- ON user_fleet_group_scopes (fleet_group_id);
-
--- +goose Down
-DROP TABLE IF EXISTS user_fleet_group_scopes;
diff --git a/db/migrations/postgres/0027_fix_job_targets_check_enum.sql b/db/migrations/postgres/0027_fix_job_targets_check_enum.sql
deleted file mode 100644
index fb4161eb..00000000
--- a/db/migrations/postgres/0027_fix_job_targets_check_enum.sql
+++ /dev/null
@@ -1,20 +0,0 @@
--- +goose Up
--- Migration 0023 added a CHECK constraint on job_targets.status with
--- the value `dispatched` — but the application enum
--- (internal/controlplane/jobs.TargetStatus) is `sent`, not
--- `dispatched`. Production writes to target.status="sent" trip the
--- constraint and bubble up as "constraint failed: CHECK constraint
--- failed" from the persist loop, leaving the row at its previous
--- value. The SQLite contract test
--- TestServiceUpdateTargetPersistsLatestVersionAfterOutOfOrderWrites
--- caught it once SQLite's mirror constraint landed in 0026.
---
--- Drop the wrong constraint and re-add it with the correct enum.
-ALTER TABLE job_targets DROP CONSTRAINT IF EXISTS job_targets_status_check;
-ALTER TABLE job_targets ADD CONSTRAINT job_targets_status_check
- CHECK (status IN ('queued','sent','acknowledged','succeeded','failed','expired'));
-
--- +goose Down
-ALTER TABLE job_targets DROP CONSTRAINT IF EXISTS job_targets_status_check;
-ALTER TABLE job_targets ADD CONSTRAINT job_targets_status_check
- CHECK (status IN ('queued','dispatched','acknowledged','succeeded','failed','expired'));
diff --git a/db/migrations/postgres/0028_cascade_fk_grants_and_discovered.sql b/db/migrations/postgres/0028_cascade_fk_grants_and_discovered.sql
deleted file mode 100644
index 1a284151..00000000
--- a/db/migrations/postgres/0028_cascade_fk_grants_and_discovered.sql
+++ /dev/null
@@ -1,32 +0,0 @@
--- +goose Up
--- Add ON DELETE CASCADE to the remaining FKs on agents (id):
--- * agent_certificate_recovery_grants — never patched.
--- * discovered_clients — never patched.
--- telemt_instances was handled in 0022_cascade_fk_telemt_instances.sql.
--- Without these, DELETE FROM agents fails when the deregistered agent
--- still has rows in either table (see http_agents.persistAgentDeregister).
-
-ALTER TABLE agent_certificate_recovery_grants
- DROP CONSTRAINT IF EXISTS agent_certificate_recovery_grants_agent_id_fkey;
-ALTER TABLE agent_certificate_recovery_grants
- ADD CONSTRAINT agent_certificate_recovery_grants_agent_id_fkey
- FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE;
-
-ALTER TABLE discovered_clients
- DROP CONSTRAINT IF EXISTS discovered_clients_agent_id_fkey;
-ALTER TABLE discovered_clients
- ADD CONSTRAINT discovered_clients_agent_id_fkey
- FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE;
-
--- +goose Down
-ALTER TABLE agent_certificate_recovery_grants
- DROP CONSTRAINT IF EXISTS agent_certificate_recovery_grants_agent_id_fkey;
-ALTER TABLE agent_certificate_recovery_grants
- ADD CONSTRAINT agent_certificate_recovery_grants_agent_id_fkey
- FOREIGN KEY (agent_id) REFERENCES agents (id);
-
-ALTER TABLE discovered_clients
- DROP CONSTRAINT IF EXISTS discovered_clients_agent_id_fkey;
-ALTER TABLE discovered_clients
- ADD CONSTRAINT discovered_clients_agent_id_fkey
- FOREIGN KEY (agent_id) REFERENCES agents (id);
diff --git a/db/migrations/postgres/0029_connection_links_array.sql b/db/migrations/postgres/0029_connection_links_array.sql
deleted file mode 100644
index 77f66906..00000000
--- a/db/migrations/postgres/0029_connection_links_array.sql
+++ /dev/null
@@ -1,31 +0,0 @@
--- +goose Up
--- Replace the single-link connection_link column with a JSON array
--- (connection_links) on both client_deployments and discovered_clients.
--- Telemt's tls_domains config emits one TLS link per domain (×host); the
--- agent used to collapse that to a single string. The panel now stores
--- and surfaces the full array.
---
--- Dev-stage: drop the legacy column. Existing rows with a single non-
--- empty link are migrated into the new JSON array first.
-
-ALTER TABLE client_deployments
- ADD COLUMN connection_links JSONB NOT NULL DEFAULT '[]'::jsonb;
-
-UPDATE client_deployments
-SET connection_links = jsonb_build_array(connection_link)
-WHERE connection_link <> '';
-
-ALTER TABLE client_deployments DROP COLUMN connection_link;
-
-ALTER TABLE discovered_clients
- ADD COLUMN connection_links JSONB NOT NULL DEFAULT '[]'::jsonb;
-
-UPDATE discovered_clients
-SET connection_links = jsonb_build_array(connection_link)
-WHERE connection_link <> '';
-
-ALTER TABLE discovered_clients DROP COLUMN connection_link;
-
--- +goose Down
--- Dev-stage: drop+recreate acceptable, no rollback.
-SELECT 1;
diff --git a/db/migrations/postgres/0030_node_transport_mode.sql b/db/migrations/postgres/0030_node_transport_mode.sql
deleted file mode 100644
index 13095cec..00000000
--- a/db/migrations/postgres/0030_node_transport_mode.sql
+++ /dev/null
@@ -1,33 +0,0 @@
--- +goose Up
--- Add transport-mode fields to agents so the panel can distinguish inbound
--- agents (the default: agent dials the panel) from outbound/reverse-tunnel
--- agents (panel dials the agent via a stored dial_address).
--- bootstrap_* columns support a one-time token exchange for outbound agents.
-
-ALTER TABLE agents
- ADD COLUMN transport_mode text NOT NULL DEFAULT 'inbound'
- CHECK (transport_mode IN ('inbound', 'outbound')),
- ADD COLUMN dial_address text,
- ADD COLUMN bootstrap_state text NOT NULL DEFAULT 'active'
- CHECK (bootstrap_state IN ('pending', 'active', 'expired', 'revoked')),
- ADD COLUMN bootstrap_token_hash bytea,
- ADD COLUMN bootstrap_expires_at timestamptz;
-
--- +goose StatementBegin
--- +goose NO TRANSACTION
-CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_agents_transport_mode ON agents(transport_mode);
--- +goose StatementEnd
-
--- Existing agents default to bootstrap_state=active (already enrolled).
--- New outbound agents are created with bootstrap_state=pending
--- (see bootstrap-flow in subsequent tasks).
-
--- +goose Down
--- +goose NO TRANSACTION
-DROP INDEX CONCURRENTLY IF EXISTS idx_agents_transport_mode;
-ALTER TABLE agents
- DROP COLUMN IF EXISTS bootstrap_expires_at,
- DROP COLUMN IF EXISTS bootstrap_token_hash,
- DROP COLUMN IF EXISTS bootstrap_state,
- DROP COLUMN IF EXISTS dial_address,
- DROP COLUMN IF EXISTS transport_mode;
diff --git a/db/migrations/postgres/0031_agent_fallback_state.sql b/db/migrations/postgres/0031_agent_fallback_state.sql
deleted file mode 100644
index 004fd847..00000000
--- a/db/migrations/postgres/0031_agent_fallback_state.sql
+++ /dev/null
@@ -1,12 +0,0 @@
--- +goose Up
-CREATE TABLE IF NOT EXISTS agent_fallback_state (
- agent_id TEXT PRIMARY KEY REFERENCES agents (id) ON DELETE CASCADE,
- entered_at_unix BIGINT NOT NULL
-);
-
-CREATE INDEX IF NOT EXISTS idx_agent_fallback_state_entered_at
- ON agent_fallback_state (entered_at_unix);
-
--- +goose Down
-DROP INDEX IF EXISTS idx_agent_fallback_state_entered_at;
-DROP TABLE IF EXISTS agent_fallback_state;
diff --git a/db/migrations/postgres/0032_password_policy.sql b/db/migrations/postgres/0032_password_policy.sql
deleted file mode 100644
index d6d25f11..00000000
--- a/db/migrations/postgres/0032_password_policy.sql
+++ /dev/null
@@ -1,11 +0,0 @@
--- +goose Up
--- S-01: configurable minimum password length stored on the panel_settings
--- singleton row. Default 10 mirrors NIST SP 800-63B "memorized secret" floor;
--- existing user passwords are NOT invalidated — policy applies only on
--- create/change.
-ALTER TABLE panel_settings
- ADD COLUMN password_min_length INTEGER NOT NULL DEFAULT 10
- CHECK (password_min_length BETWEEN 8 AND 128);
-
--- +goose Down
-ALTER TABLE panel_settings DROP COLUMN password_min_length;
diff --git a/db/migrations/postgres/0033_agent_cert_pin.sql b/db/migrations/postgres/0033_agent_cert_pin.sql
deleted file mode 100644
index d5564688..00000000
--- a/db/migrations/postgres/0033_agent_cert_pin.sql
+++ /dev/null
@@ -1,23 +0,0 @@
--- +goose Up
--- +goose NO TRANSACTION
--- S-02: pin the SubjectPublicKeyInfo (SPKI) SHA-256 of the agent's serving
--- certificate. Set on first successful enroll; subsequent dials verify the
--- presented cert hashes to the same value. Defends against MITM during
--- post-enroll handshakes when an attacker holds a forged cert from any CA
--- the agent trusts.
---
--- NO TRANSACTION pragma: required because CREATE/DROP INDEX CONCURRENTLY
--- cannot run inside a transaction. ALTER TABLE ADD COLUMN does take an
--- ACCESS EXCLUSIVE lock briefly, but the column has a constant DEFAULT
--- so PG 11+ skips the table rewrite.
-ALTER TABLE agents
- ADD COLUMN cert_spki_sha256 BYTEA NOT NULL DEFAULT ''::bytea
- CHECK (length(cert_spki_sha256) IN (0, 32));
-
-CREATE INDEX CONCURRENTLY idx_agents_cert_spki_sha256
- ON agents (cert_spki_sha256)
- WHERE length(cert_spki_sha256) > 0;
-
--- +goose Down
-DROP INDEX CONCURRENTLY IF EXISTS idx_agents_cert_spki_sha256;
-ALTER TABLE agents DROP COLUMN IF EXISTS cert_spki_sha256;
diff --git a/db/migrations/postgres/0034_geoip_settings.sql b/db/migrations/postgres/0034_geoip_settings.sql
deleted file mode 100644
index 12b19703..00000000
--- a/db/migrations/postgres/0034_geoip_settings.sql
+++ /dev/null
@@ -1,13 +0,0 @@
--- +goose Up
--- Adds geoip_json (operator-managed config) and geoip_state_json
--- (worker-managed runtime state — last check / etag / size / error)
--- to the singleton scope='panel' row. Mirrors the retention_json /
--- update_settings pattern.
-ALTER TABLE panel_settings
- ADD COLUMN IF NOT EXISTS geoip_json TEXT NOT NULL DEFAULT '';
-ALTER TABLE panel_settings
- ADD COLUMN IF NOT EXISTS geoip_state_json TEXT NOT NULL DEFAULT '';
-
--- +goose Down
-ALTER TABLE panel_settings DROP COLUMN IF EXISTS geoip_state_json;
-ALTER TABLE panel_settings DROP COLUMN IF EXISTS geoip_json;
diff --git a/db/migrations/postgres/0035_telemt_unreachable.sql b/db/migrations/postgres/0035_telemt_unreachable.sql
deleted file mode 100644
index 2ddee363..00000000
--- a/db/migrations/postgres/0035_telemt_unreachable.sql
+++ /dev/null
@@ -1,13 +0,0 @@
--- +goose Up
--- +goose StatementBegin
-ALTER TABLE telemt_runtime_current
- ADD COLUMN telemt_reachable BOOLEAN NOT NULL DEFAULT TRUE,
- ADD COLUMN telemt_unreachable_since_unix BIGINT NOT NULL DEFAULT 0;
--- +goose StatementEnd
-
--- +goose Down
--- +goose StatementBegin
-ALTER TABLE telemt_runtime_current
- DROP COLUMN IF EXISTS telemt_unreachable_since_unix,
- DROP COLUMN IF EXISTS telemt_reachable;
--- +goose StatementEnd
diff --git a/db/migrations/postgres/0036_drop_bootstrap_columns_from_panel_settings.sql b/db/migrations/postgres/0036_drop_bootstrap_columns_from_panel_settings.sql
deleted file mode 100644
index 963e421a..00000000
--- a/db/migrations/postgres/0036_drop_bootstrap_columns_from_panel_settings.sql
+++ /dev/null
@@ -1,13 +0,0 @@
--- +goose Up
--- 0036: drop bootstrap-only columns from panel_settings.
--- Project is pre-release; no compatibility shim.
-
-ALTER TABLE panel_settings DROP COLUMN IF EXISTS http_listen_address;
-ALTER TABLE panel_settings DROP COLUMN IF EXISTS grpc_listen_address;
-ALTER TABLE panel_settings DROP COLUMN IF EXISTS tls_mode;
-ALTER TABLE panel_settings DROP COLUMN IF EXISTS tls_cert_file;
-ALTER TABLE panel_settings DROP COLUMN IF EXISTS tls_key_file;
-ALTER TABLE panel_settings DROP COLUMN IF EXISTS http_root_path;
-
--- +goose Down
--- intentionally empty (pre-release, no compatibility shim)
diff --git a/db/migrations/postgres/0037_runtime_settings.sql b/db/migrations/postgres/0037_runtime_settings.sql
deleted file mode 100644
index fd9583be..00000000
--- a/db/migrations/postgres/0037_runtime_settings.sql
+++ /dev/null
@@ -1,11 +0,0 @@
--- +goose Up
--- 0037: kv-table for operational settings without dedicated columns.
-CREATE TABLE runtime_settings (
- name TEXT PRIMARY KEY,
- value_json TEXT NOT NULL,
- updated_at BIGINT NOT NULL,
- updated_by TEXT NOT NULL DEFAULT ''
-);
-
--- +goose Down
-DROP TABLE runtime_settings;
diff --git a/db/migrations/postgres/0038_audit_hash_chain.sql b/db/migrations/postgres/0038_audit_hash_chain.sql
deleted file mode 100644
index 4dd7498a..00000000
--- a/db/migrations/postgres/0038_audit_hash_chain.sql
+++ /dev/null
@@ -1,26 +0,0 @@
--- +goose Up
--- +goose NO TRANSACTION
--- 0038: tamper-evident audit chain.
--- Project is pre-release; existing rows keep the empty default and the
--- chain begins forming on the first post-migration write. The
--- verify-audit-chain subcommand treats consecutive empty hashes at
--- the head of the table as the legacy/genesis prefix.
---
--- NO TRANSACTION because CREATE INDEX CONCURRENTLY cannot run inside
--- a transaction. ADD COLUMN ... DEFAULT '' is metadata-only on
--- PostgreSQL 11+, so the lack of an outer transaction does not impede
--- atomicity in practice — the column is visible only after the
--- ALTER TABLE returns.
-
-ALTER TABLE audit_events ADD COLUMN prev_hash TEXT NOT NULL DEFAULT '';
-ALTER TABLE audit_events ADD COLUMN event_hash TEXT NOT NULL DEFAULT '';
-
--- Helper index for the chain walker: the verifier reads in
--- (created_at, id) ascending order, which the existing primary key
--- only partially covers.
-CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_audit_events_chain_walk
- ON audit_events (created_at, id);
-
--- +goose Down
--- +goose NO TRANSACTION
--- intentionally empty (pre-release, no compatibility shim)
diff --git a/db/migrations/postgres/0039_webhook_outbox.sql b/db/migrations/postgres/0039_webhook_outbox.sql
deleted file mode 100644
index c462072c..00000000
--- a/db/migrations/postgres/0039_webhook_outbox.sql
+++ /dev/null
@@ -1,40 +0,0 @@
--- +goose Up
--- 0039: webhook outbox + endpoints.
--- Two tables: endpoints define receivers; outbox queues pending
--- deliveries with retry / dead-letter state. Worker polls outbox,
--- HMAC-signs body, exponential-backoff on failure.
-
-CREATE TABLE webhook_endpoints (
- id TEXT PRIMARY KEY,
- name TEXT NOT NULL UNIQUE,
- url TEXT NOT NULL,
- secret_ciphertext TEXT NOT NULL,
- event_filter TEXT NOT NULL DEFAULT '',
- allow_private BOOLEAN NOT NULL DEFAULT FALSE,
- enabled BOOLEAN NOT NULL DEFAULT TRUE,
- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
- updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
-);
-
-CREATE TABLE webhook_outbox (
- id TEXT PRIMARY KEY,
- endpoint_id TEXT NOT NULL REFERENCES webhook_endpoints(id) ON DELETE CASCADE,
- event_action TEXT NOT NULL,
- payload JSONB NOT NULL,
- attempt INTEGER NOT NULL DEFAULT 0,
- next_attempt_at TIMESTAMPTZ NOT NULL,
- last_error TEXT NOT NULL DEFAULT '',
- dead BOOLEAN NOT NULL DEFAULT FALSE,
- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
- delivered_at TIMESTAMPTZ
-);
-
--- Worker's claim query orders by next_attempt_at ASC for live rows
--- (dead=false). Partial index keeps the hot scan small even with a
--- huge dead-letter tail.
-CREATE INDEX idx_webhook_outbox_ready
- ON webhook_outbox (next_attempt_at)
- WHERE dead = FALSE AND delivered_at IS NULL;
-
--- +goose Down
--- intentionally empty (pre-release, no compatibility shim)
diff --git a/db/migrations/postgres/0040_invert_telemt_reachability.sql b/db/migrations/postgres/0040_invert_telemt_reachability.sql
deleted file mode 100644
index 3e20d38c..00000000
--- a/db/migrations/postgres/0040_invert_telemt_reachability.sql
+++ /dev/null
@@ -1,18 +0,0 @@
--- +goose Up
--- +goose StatementBegin
--- Invert the telemt reachability flag so proto3's bool default (false)
--- correctly represents the healthy/reachable case. Previously
--- telemt_reachable=TRUE meant healthy; now telemt_unreachable=FALSE means healthy.
-ALTER TABLE telemt_runtime_current
- ADD COLUMN telemt_unreachable BOOLEAN NOT NULL DEFAULT FALSE;
-UPDATE telemt_runtime_current SET telemt_unreachable = NOT telemt_reachable;
-ALTER TABLE telemt_runtime_current DROP COLUMN telemt_reachable;
--- +goose StatementEnd
-
--- +goose Down
--- +goose StatementBegin
-ALTER TABLE telemt_runtime_current
- ADD COLUMN telemt_reachable BOOLEAN NOT NULL DEFAULT TRUE;
-UPDATE telemt_runtime_current SET telemt_reachable = NOT telemt_unreachable;
-ALTER TABLE telemt_runtime_current DROP COLUMN telemt_unreachable;
--- +goose StatementEnd
diff --git a/db/migrations/postgres/0041_enrollment_attempts.sql b/db/migrations/postgres/0041_enrollment_attempts.sql
deleted file mode 100644
index 7c5be3f9..00000000
--- a/db/migrations/postgres/0041_enrollment_attempts.sql
+++ /dev/null
@@ -1,38 +0,0 @@
--- +goose Up
-CREATE TABLE enrollment_attempts (
- id UUID PRIMARY KEY,
- token_id UUID,
- agent_id UUID,
- mode TEXT NOT NULL CHECK (mode IN ('inbound', 'outbound')),
- client_addr TEXT,
- request_id TEXT NOT NULL,
- status TEXT NOT NULL CHECK (status IN ('in_progress', 'success', 'failed')),
- error_code TEXT,
- error_message TEXT,
- started_at TIMESTAMPTZ NOT NULL,
- finished_at TIMESTAMPTZ
-);
-
-CREATE INDEX idx_enrollment_attempts_token ON enrollment_attempts(token_id);
-CREATE INDEX idx_enrollment_attempts_agent ON enrollment_attempts(agent_id);
-CREATE INDEX idx_enrollment_attempts_started ON enrollment_attempts(started_at);
-
-CREATE TABLE enrollment_events (
- id BIGSERIAL PRIMARY KEY,
- attempt_id UUID NOT NULL REFERENCES enrollment_attempts(id) ON DELETE CASCADE,
- ts TIMESTAMPTZ NOT NULL,
- step TEXT NOT NULL,
- level TEXT NOT NULL CHECK (level IN ('info', 'warn', 'error')),
- message TEXT,
- fields_json JSONB
-);
-
-CREATE INDEX idx_enrollment_events_attempt ON enrollment_events(attempt_id, ts);
-
--- +goose Down
-DROP INDEX IF EXISTS idx_enrollment_events_attempt;
-DROP TABLE IF EXISTS enrollment_events;
-DROP INDEX IF EXISTS idx_enrollment_attempts_started;
-DROP INDEX IF EXISTS idx_enrollment_attempts_agent;
-DROP INDEX IF EXISTS idx_enrollment_attempts_token;
-DROP TABLE IF EXISTS enrollment_attempts;
diff --git a/db/migrations/postgres/0042_client_deployment_last_reset.sql b/db/migrations/postgres/0042_client_deployment_last_reset.sql
deleted file mode 100644
index 92410643..00000000
--- a/db/migrations/postgres/0042_client_deployment_last_reset.sql
+++ /dev/null
@@ -1,16 +0,0 @@
--- +goose Up
--- +goose StatementBegin
--- Phase 3: per-(client, agent) record of when the panel last applied a
--- successful quota reset. Compared against Telemt's reported
--- last_reset_epoch_secs on each ClientUsage snapshot to detect drift —
--- i.e. cases where the panel-driven reset job succeeded but Telemt's
--- persisted quota state has fallen behind (Telemt restart before
--- sidecar flush, raw curl reset out of band, etc.).
-ALTER TABLE client_deployments
- ADD COLUMN last_reset_epoch_secs BIGINT NOT NULL DEFAULT 0;
--- +goose StatementEnd
-
--- +goose Down
--- +goose StatementBegin
-ALTER TABLE client_deployments DROP COLUMN IF EXISTS last_reset_epoch_secs;
--- +goose StatementEnd
diff --git a/db/migrations/postgres/0043_client_usage_quota.sql b/db/migrations/postgres/0043_client_usage_quota.sql
deleted file mode 100644
index 97712809..00000000
--- a/db/migrations/postgres/0043_client_usage_quota.sql
+++ /dev/null
@@ -1,21 +0,0 @@
--- +goose Up
--- +goose StatementBegin
--- IN-H2: persist the per-(client, agent) quota counters alongside the other
--- usage fields. Previously quota_used_bytes / quota_last_reset_unix lived
--- only in the in-memory mirror, so a panel restart snapped them to 0 until
--- the next agent usage tick repopulated them.
-ALTER TABLE client_usage
- ADD COLUMN quota_used_bytes BIGINT NOT NULL DEFAULT 0;
--- +goose StatementEnd
--- +goose StatementBegin
-ALTER TABLE client_usage
- ADD COLUMN quota_last_reset_unix BIGINT NOT NULL DEFAULT 0;
--- +goose StatementEnd
-
--- +goose Down
--- +goose StatementBegin
-ALTER TABLE client_usage DROP COLUMN IF EXISTS quota_last_reset_unix;
--- +goose StatementEnd
--- +goose StatementBegin
-ALTER TABLE client_usage DROP COLUMN IF EXISTS quota_used_bytes;
--- +goose StatementEnd
diff --git a/db/migrations/postgres/0044_drop_enrollment_token_value_hash.sql b/db/migrations/postgres/0044_drop_enrollment_token_value_hash.sql
deleted file mode 100644
index 5d6b66d4..00000000
--- a/db/migrations/postgres/0044_drop_enrollment_token_value_hash.sql
+++ /dev/null
@@ -1,10 +0,0 @@
--- +goose Up
--- L-4: drop the dead enrollment_tokens.value_hash column. Enrollment tokens
--- are stored plaintext (TTL-bounded); the hashing this column was meant to
--- back was never implemented, leaving it a permanently-empty dead column.
--- Pre-release, no compatibility shim. DROP COLUMN cascades to the index
--- idx_enrollment_tokens_value_hash created in migration 0021.
-ALTER TABLE enrollment_tokens DROP COLUMN IF EXISTS value_hash;
-
--- +goose Down
--- intentionally empty (pre-release, no compatibility shim)
diff --git a/db/migrations/postgres/0045_client_deployment_link_diagnostic.sql b/db/migrations/postgres/0045_client_deployment_link_diagnostic.sql
deleted file mode 100644
index 43a40d5d..00000000
--- a/db/migrations/postgres/0045_client_deployment_link_diagnostic.sql
+++ /dev/null
@@ -1,15 +0,0 @@
--- +goose Up
--- +goose StatementBegin
--- IN-M2: operator-facing diagnostic stamped on a successful (non-delete)
--- apply when the node returned no connection links. In that case the
--- existing connection_links are kept but may be stale after a host or
--- secret change; this column explains why so the dashboard can flag the
--- link instead of silently serving it. Empty string means "no issue".
-ALTER TABLE client_deployments
- ADD COLUMN link_diagnostic TEXT NOT NULL DEFAULT '';
--- +goose StatementEnd
-
--- +goose Down
--- +goose StatementBegin
-ALTER TABLE client_deployments DROP COLUMN IF EXISTS link_diagnostic;
--- +goose StatementEnd
diff --git a/db/migrations/postgres/0046_rename_connected_users_to_connections.sql b/db/migrations/postgres/0046_rename_connected_users_to_connections.sql
deleted file mode 100644
index 4df28ee1..00000000
--- a/db/migrations/postgres/0046_rename_connected_users_to_connections.sql
+++ /dev/null
@@ -1,9 +0,0 @@
--- +goose Up
--- IN-M3: the telemt_instances.connected_users column actually holds the
--- telemt CurrentConnections counter (number of TCP connections), not a
--- distinct-users count. Rename the column end-to-end to reflect its true
--- meaning. Value semantics are unchanged.
-ALTER TABLE telemt_instances RENAME COLUMN connected_users TO connections;
-
--- +goose Down
-ALTER TABLE telemt_instances RENAME COLUMN connections TO connected_users;
diff --git a/db/migrations/postgres/0047_drop_telemt_detail_boosts.sql b/db/migrations/postgres/0047_drop_telemt_detail_boosts.sql
deleted file mode 100644
index c920c48d..00000000
--- a/db/migrations/postgres/0047_drop_telemt_detail_boosts.sql
+++ /dev/null
@@ -1,13 +0,0 @@
--- +goose Up
--- F4: drop the persisted detail-boost table. Detail boost is an ephemeral
--- ~10m telemetry-frequency bump for a single agent and now lives only in
--- memory on the panel (s.detailBoosts). It is intentionally not durable —
--- if the panel restarts the operator simply re-enables it.
-DROP TABLE IF EXISTS telemt_detail_boosts;
-
--- +goose Down
-CREATE TABLE IF NOT EXISTS telemt_detail_boosts (
- agent_id TEXT PRIMARY KEY REFERENCES agents (id) ON DELETE CASCADE,
- expires_at TIMESTAMPTZ NOT NULL,
- updated_at TIMESTAMPTZ NOT NULL
-);
diff --git a/db/migrations/postgres/0048_jobs_status_partial.sql b/db/migrations/postgres/0048_jobs_status_partial.sql
deleted file mode 100644
index a1d2db80..00000000
--- a/db/migrations/postgres/0048_jobs_status_partial.sql
+++ /dev/null
@@ -1,14 +0,0 @@
--- +goose Up
--- F2: deriveJobStatus now emits a new terminal job status, "partial", for a
--- MIXED outcome (at least one target succeeded AND at least one ended
--- terminally-unsuccessful). The jobs_status_check constraint added in 0023
--- only allowed queued/running/succeeded/failed/expired, so persisting a
--- partial job tripped the CHECK and the write was dropped. Widen the enum.
-ALTER TABLE jobs DROP CONSTRAINT IF EXISTS jobs_status_check;
-ALTER TABLE jobs ADD CONSTRAINT jobs_status_check
- CHECK (status IN ('queued','running','succeeded','failed','expired','partial'));
-
--- +goose Down
-ALTER TABLE jobs DROP CONSTRAINT IF EXISTS jobs_status_check;
-ALTER TABLE jobs ADD CONSTRAINT jobs_status_check
- CHECK (status IN ('queued','running','succeeded','failed','expired'));
diff --git a/db/migrations/postgres/0049_agent_config_targets.sql b/db/migrations/postgres/0049_agent_config_targets.sql
deleted file mode 100644
index 4828525d..00000000
--- a/db/migrations/postgres/0049_agent_config_targets.sql
+++ /dev/null
@@ -1,17 +0,0 @@
--- +goose Up
--- agent_config_targets stores the operator's DESIRED Telemt config per scope.
--- scope_type is 'group' (scope_id = fleet_groups.id) or 'agent' (scope_id =
--- agents.id). sections_json is a sparse JSON object of editable config sections
--- (general/timeouts/censorship/upstreams/show_link/dc_overrides). The effective
--- config of an agent is its group target merged with its agent override.
-CREATE TABLE IF NOT EXISTS agent_config_targets (
- scope_type TEXT NOT NULL,
- scope_id TEXT NOT NULL,
- sections_json TEXT NOT NULL DEFAULT '{}',
- created_at TIMESTAMPTZ NOT NULL,
- updated_at TIMESTAMPTZ NOT NULL,
- PRIMARY KEY (scope_type, scope_id)
-);
-
--- +goose Down
-DROP TABLE IF EXISTS agent_config_targets;
diff --git a/db/migrations/postgres/0050_check_parity.sql b/db/migrations/postgres/0050_check_parity.sql
deleted file mode 100644
index c30986c1..00000000
--- a/db/migrations/postgres/0050_check_parity.sql
+++ /dev/null
@@ -1,17 +0,0 @@
--- +goose Up
--- C1: Align enrollment_tokens.fleet_group_id FK with the SET NULL convention.
--- A group deleted out from under outstanding enrollment tokens should null their
--- scope, not block the delete (NO ACTION) and not silently vanish; this matches
--- the provider_id SET NULL convention and the documented enrollment design.
--- The two CHECK constraints (panel_settings.password_min_length and
--- agents.cert_spki_sha256) already exist in Postgres; no changes needed there.
-ALTER TABLE enrollment_tokens DROP CONSTRAINT enrollment_tokens_fleet_group_id_fkey;
-ALTER TABLE enrollment_tokens
- ADD CONSTRAINT enrollment_tokens_fleet_group_id_fkey
- FOREIGN KEY (fleet_group_id) REFERENCES fleet_groups (id) ON DELETE SET NULL;
-
--- +goose Down
-ALTER TABLE enrollment_tokens DROP CONSTRAINT enrollment_tokens_fleet_group_id_fkey;
-ALTER TABLE enrollment_tokens
- ADD CONSTRAINT enrollment_tokens_fleet_group_id_fkey
- FOREIGN KEY (fleet_group_id) REFERENCES fleet_groups (id);
diff --git a/db/migrations/postgres/0051_client_subscription_token.sql b/db/migrations/postgres/0051_client_subscription_token.sql
deleted file mode 100644
index 38121aa1..00000000
--- a/db/migrations/postgres/0051_client_subscription_token.sql
+++ /dev/null
@@ -1,19 +0,0 @@
--- +goose Up
--- +goose NO TRANSACTION
--- subscription_token is the opaque, unguessable handle embedded in a client's
--- public subscription URL (/sub/). Nullable: legacy rows get a token
--- when the operator rotates (no backfill — pre-prod). UNIQUE so a token maps
--- to at most one client; the partial predicate lets multiple rows stay NULL.
---
--- NO TRANSACTION pragma: required because CREATE/DROP INDEX CONCURRENTLY
--- cannot run inside a transaction. ALTER TABLE ADD COLUMN takes an ACCESS
--- EXCLUSIVE lock briefly, but the column has no DEFAULT so PG skips the
--- table rewrite.
-ALTER TABLE clients ADD COLUMN subscription_token TEXT;
-CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS clients_subscription_token_key
- ON clients (subscription_token)
- WHERE subscription_token IS NOT NULL;
-
--- +goose Down
-DROP INDEX CONCURRENTLY IF EXISTS clients_subscription_token_key;
-ALTER TABLE clients DROP COLUMN subscription_token;
diff --git a/db/migrations/postgres/0052_integration_providers_config_text.sql b/db/migrations/postgres/0052_integration_providers_config_text.sql
deleted file mode 100644
index f601479f..00000000
--- a/db/migrations/postgres/0052_integration_providers_config_text.sql
+++ /dev/null
@@ -1,87 +0,0 @@
--- +goose Up
--- +goose NO TRANSACTION
--- bug1/H-6: integration_providers.config is vault-encrypted as a WHOLE
--- blob, not per-field. fleet.Service.encryptProviderConfig
--- (internal/controlplane/fleet/service.go) seals this column's plaintext
--- under the vault's "integration_config" domain whenever a secretvault is
--- configured (SetVault) — the production default, since
--- config.LoadBootstrap requires PANVEX_ENCRYPTION_KEY. The sealed value is
--- a "PVS1:"/"PVS2:"/"PVS3:"-prefixed ciphertext string, not JSON.
---
--- On PostgreSQL this column was JSONB (db/migrations/postgres/0001_init.sql),
--- and postgres/integrations.go's CreateIntegrationProvider/
--- UpdateIntegrationProvider bound the config parameter with an explicit
--- `$N::jsonb` cast. That cast rejects any non-JSON string at write time,
--- so creating or updating an integration provider on a PostgreSQL-backed
--- install with vault encryption enabled failed with "invalid input syntax
--- for type json" (SQLSTATE 22P02) on every write — the write path was
--- completely broken whenever the (default-on) vault was active.
---
--- SQLite never had this problem: integration_providers.config there is
--- plain TEXT, and db/migrations/sqlite/0052_json_valid_checks.sql gave it
--- a permissive CHECK — `json_valid(config) OR config LIKE 'PVS_:%'` — that
--- accepts either plain JSON or a vault-sealed ciphertext string. This
--- migration brings PostgreSQL to the same shape: the column becomes TEXT
--- (dropping JSONB's native validation, which is exactly the problem) and
--- gains an equivalent CHECK expressed with PostgreSQL's `IS JSON`
--- predicate (available since PG 16; this project's CI runs postgres:16-
--- alpine and prod runs postgres:18-alpine, so the predicate is safe to
--- rely on unconditionally).
---
--- fleet_group_integrations.config is intentionally NOT touched: only
--- provider configs ever go through encryptProviderConfig/
--- decryptProviderConfig (fleet/service.go's CreateProvider, UpdateProvider,
--- GetProvider, ListProviders call sites) — fleet-group-integration configs
--- are always plain, never-encrypted JSON, so that column's `::jsonb` casts
--- in postgres/integrations.go and its plain JSONB type remain correct.
---
--- NO TRANSACTION pragma: matches the convention used by other single-table
--- ALTER TABLE migrations in this bundle (e.g. 0051); ALTER COLUMN TYPE here
--- takes an ACCESS EXCLUSIVE lock and rewrites the table, but that rewrite
--- itself does not require running outside a transaction — NO TRANSACTION
--- is kept for consistency with the rest of this migration's straightforward
--- single-statement-group shape and to avoid goose wrapping unrelated DDL
--- together.
-ALTER TABLE integration_providers
- ALTER COLUMN config TYPE text USING config::text;
-
--- ALTER COLUMN TYPE preserves the NOT NULL flag automatically, but the
--- DEFAULT survives as a stale `'{}'::jsonb` expression (verified against a
--- scratch PostgreSQL 18 table: `\d+` after the TYPE change still shows
--- `default '{}'::jsonb` even though the column is now text). PostgreSQL
--- happens to still accept inserts against that stale default because it
--- implicitly casts jsonb back to text at execution time, but the
--- rendered default is misleading (claims a type the column no longer has)
--- and would confuse both the schema-sync comparator's diagnostics output
--- and any human reading `\d+`. Re-declare it explicitly as a plain text
--- default so introspection reports the column truthfully.
-ALTER TABLE integration_providers
- ALTER COLUMN config SET DEFAULT '{}';
-
--- Compensating control matching SQLite's permissive json_valid CHECK (see
--- header). The single-char wildcard in 'PVS_:%' covers all three sealed-
--- prefix generations (PVS1/PVS2/PVS3) without hardcoding each one, mirroring
--- the SQLite side's `LIKE 'PVS_:%'` pattern exactly.
-ALTER TABLE integration_providers
- ADD CONSTRAINT integration_providers_config_check
- CHECK (config LIKE 'PVS_:%' OR config IS JSON);
-
--- +goose Down
--- Reversing this is best-effort: it will fail with a cast error if any row
--- currently holds a vault-sealed "PVSn:"-prefixed ciphertext string, since
--- that string is not valid JSON and cannot be cast back to jsonb. This
--- mirrors how the forward migration itself only became safe to write
--- because, at deploy time, no row is yet ciphertext — the Down direction
--- carries no such guarantee once the fix has been live and providers have
--- been created/updated under vault encryption. Operators rolling back after
--- go-live must first re-encrypt or clear sealed rows out-of-band; this is
--- an accepted, documented limitation (same posture as the irreversible-ish
--- Down blocks elsewhere in this bundle, e.g. 0044's dropped-column Down).
-ALTER TABLE integration_providers
- DROP CONSTRAINT integration_providers_config_check;
-
-ALTER TABLE integration_providers
- ALTER COLUMN config TYPE jsonb USING config::jsonb;
-
-ALTER TABLE integration_providers
- ALTER COLUMN config SET DEFAULT '{}';
diff --git a/db/migrations/postgres/0053_config_apply_batches.sql b/db/migrations/postgres/0053_config_apply_batches.sql
deleted file mode 100644
index 7239f3ac..00000000
--- a/db/migrations/postgres/0053_config_apply_batches.sql
+++ /dev/null
@@ -1,44 +0,0 @@
--- +goose Up
--- config_apply_batches coordinates one group-wide config-apply rollout: an
--- operator triggers a config push to every agent in a fleet group, and the
--- batch tracks delivery across one or more waves depending on mode
--- (all_at_once vs rolling). expected_revision pins the agent_config_targets
--- revision this batch is rolling out so a concurrent edit to the group's
--- desired config cannot silently change what an in-flight batch delivers.
---
--- fleet_group_id is UUID (not TEXT) to match fleet_groups.id's type on
--- PostgreSQL (postgres/0014_fleet_groups_redesign.sql) — PostgreSQL refuses
--- to create a FOREIGN KEY between mismatched column types. See
--- sqlite/0053_config_apply_batches.sql for the TEXT-typed mirror (SQLite
--- kept fleet_groups.id as TEXT — see sqlite/0014's header note).
-CREATE TABLE IF NOT EXISTS config_apply_batches (
- id TEXT PRIMARY KEY,
- fleet_group_id UUID NOT NULL REFERENCES fleet_groups (id) ON DELETE CASCADE,
- mode TEXT NOT NULL CHECK (mode IN ('all_at_once', 'rolling')),
- wave_size INT NOT NULL DEFAULT 1,
- expected_revision TEXT NOT NULL DEFAULT '',
- status TEXT NOT NULL CHECK (status IN ('running', 'succeeded', 'failed', 'halted')),
- created_at TIMESTAMPTZ NOT NULL,
- updated_at TIMESTAMPTZ NOT NULL
-);
-
-CREATE INDEX IF NOT EXISTS idx_config_apply_batches_status
- ON config_apply_batches (status);
-
--- config_apply_batch_targets is one agent's delivery record within a batch.
--- job_id is '' until the target's wave is enqueued.
-CREATE TABLE IF NOT EXISTS config_apply_batch_targets (
- batch_id TEXT NOT NULL REFERENCES config_apply_batches (id) ON DELETE CASCADE,
- agent_id TEXT NOT NULL,
- wave_index INT NOT NULL,
- job_id TEXT NOT NULL DEFAULT '',
- status TEXT NOT NULL CHECK (status IN ('pending', 'running', 'succeeded', 'failed', 'skipped')),
- PRIMARY KEY (batch_id, agent_id)
-);
-
-CREATE INDEX IF NOT EXISTS idx_config_apply_batch_targets_batch_wave
- ON config_apply_batch_targets (batch_id, wave_index);
-
--- +goose Down
-DROP TABLE IF EXISTS config_apply_batch_targets;
-DROP TABLE IF EXISTS config_apply_batches;
diff --git a/db/migrations/postgres/0054_config_apply_batch_target_message.sql b/db/migrations/postgres/0054_config_apply_batch_target_message.sql
deleted file mode 100644
index 2bfff448..00000000
--- a/db/migrations/postgres/0054_config_apply_batch_target_message.sql
+++ /dev/null
@@ -1,16 +0,0 @@
--- +goose Up
--- +goose StatementBegin
--- Persists a failed (or otherwise terminal) target's message so the
--- resumable batch-status view (GET /fleet-groups/{id}/config/apply/batches/{batchId})
--- can surface the failure reason after the underlying config.apply job has
--- been evicted from the in-memory jobs store. Previously the message only
--- lived on the live jobs.Job target (ResultText), which is lost once the
--- job rolls off — defeating the point of a persistent, resumable batch.
-ALTER TABLE config_apply_batch_targets
- ADD COLUMN message TEXT NOT NULL DEFAULT '';
--- +goose StatementEnd
-
--- +goose Down
--- +goose StatementBegin
-ALTER TABLE config_apply_batch_targets DROP COLUMN IF EXISTS message;
--- +goose StatementEnd
diff --git a/db/migrations/postgres/0055_runtime_current_json.sql b/db/migrations/postgres/0055_runtime_current_json.sql
deleted file mode 100644
index 4ab66f87..00000000
--- a/db/migrations/postgres/0055_runtime_current_json.sql
+++ /dev/null
@@ -1,13 +0,0 @@
--- +goose Up
--- P3-3.1 (аудит #3): см. sqlite/0055_runtime_current_json.sql. Колонки и
--- типы зеркалят SQLite по конвенции *_at_unix ↔ TIMESTAMPTZ
--- (schema_sync_test нормализует суффиксы _unix/_at).
-DROP TABLE telemt_runtime_current;
-CREATE TABLE telemt_runtime_current (
- agent_id TEXT PRIMARY KEY REFERENCES agents (id) ON DELETE CASCADE,
- observed_at TIMESTAMPTZ NOT NULL,
- runtime_json TEXT NOT NULL DEFAULT ''
-);
-
--- +goose Down
-SELECT 1;
diff --git a/db/migrations/postgres/0056_config_apply_batch_group_nullable.sql b/db/migrations/postgres/0056_config_apply_batch_group_nullable.sql
deleted file mode 100644
index 71e3e969..00000000
--- a/db/migrations/postgres/0056_config_apply_batch_group_nullable.sql
+++ /dev/null
@@ -1,8 +0,0 @@
--- +goose Up
--- P3-3.4 (аудит #25a): одиночный config-apply становится batch-of-one без
--- fleet-group-скоупа. NULL fleet_group_id = agent-scoped батч; групповой
--- active-lookup (WHERE fleet_group_id = $1) такие батчи не видит.
-ALTER TABLE config_apply_batches ALTER COLUMN fleet_group_id DROP NOT NULL;
-
--- +goose Down
-SELECT 1;
diff --git a/db/migrations/postgres/0057_client_usage_watermark.sql b/db/migrations/postgres/0057_client_usage_watermark.sql
deleted file mode 100644
index 23fb3c27..00000000
--- a/db/migrations/postgres/0057_client_usage_watermark.sql
+++ /dev/null
@@ -1,9 +0,0 @@
--- +goose Up
--- P4: см. sqlite/0057_client_usage_watermark.sql (watermark кумулятивного
--- счётчика агента; additive — last_seq уходит в 0058).
-ALTER TABLE client_usage ADD COLUMN IF NOT EXISTS agent_boot_id TEXT NOT NULL DEFAULT '';
-ALTER TABLE client_usage ADD COLUMN IF NOT EXISTS last_total_bytes BIGINT NOT NULL DEFAULT 0;
-
--- +goose Down
-ALTER TABLE client_usage DROP COLUMN IF EXISTS last_total_bytes;
-ALTER TABLE client_usage DROP COLUMN IF EXISTS agent_boot_id;
diff --git a/db/migrations/postgres/0058_client_usage_drop_last_seq.sql b/db/migrations/postgres/0058_client_usage_drop_last_seq.sql
deleted file mode 100644
index 98bb443a..00000000
--- a/db/migrations/postgres/0058_client_usage_drop_last_seq.sql
+++ /dev/null
@@ -1,6 +0,0 @@
--- +goose Up
--- P4: см. sqlite/0058_client_usage_drop_last_seq.sql.
-ALTER TABLE client_usage DROP COLUMN IF EXISTS last_seq;
-
--- +goose Down
-ALTER TABLE client_usage ADD COLUMN IF NOT EXISTS last_seq BIGINT NOT NULL DEFAULT 0;
diff --git a/db/migrations/sqlite/0001_init.sql b/db/migrations/sqlite/0001_init.sql
index 5dc3d6b2..c39b47ba 100644
--- a/db/migrations/sqlite/0001_init.sql
+++ b/db/migrations/sqlite/0001_init.sql
@@ -1,30 +1,42 @@
-- +goose Up
-CREATE TABLE IF NOT EXISTS users (
- id TEXT PRIMARY KEY,
- username TEXT NOT NULL UNIQUE,
- password_hash TEXT NOT NULL,
- role TEXT NOT NULL,
- totp_enabled INTEGER NOT NULL DEFAULT 0,
- totp_secret TEXT NOT NULL DEFAULT '',
- created_at_unix INTEGER NOT NULL
+-- P9 squash (2026-07): консолидация миграций 0001..0058 в один init.
+-- Файл сгенерирован дампом sqlite_master свежей БД, смигрированной
+-- полным историческим деревом (tools/squashtool, задача P9-3).
+-- НЕ редактировать вручную задним числом: изменения схемы = новая
+-- миграция с номером >= 0059 (см. db/migrations/README.md).
+
+CREATE TABLE "agent_certificate_recovery_grants" (
+ agent_id TEXT PRIMARY KEY,
+ issued_by TEXT NOT NULL,
+ issued_at_unix INTEGER NOT NULL,
+ expires_at_unix INTEGER NOT NULL,
+ used_at_unix INTEGER,
+ revoked_at_unix INTEGER,
+ FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE
);
-CREATE TABLE IF NOT EXISTS user_appearance (
- user_id TEXT PRIMARY KEY,
- theme TEXT NOT NULL DEFAULT 'system',
- density TEXT NOT NULL DEFAULT 'comfortable',
- help_mode TEXT NOT NULL DEFAULT 'basic',
- updated_at_unix INTEGER NOT NULL DEFAULT 0,
- FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
+CREATE TABLE agent_config_targets (
+ scope_type TEXT NOT NULL,
+ scope_id TEXT NOT NULL,
+ sections_json TEXT NOT NULL DEFAULT '{}',
+ created_at TIMESTAMP NOT NULL,
+ updated_at TIMESTAMP NOT NULL,
+ PRIMARY KEY (scope_type, scope_id)
);
-CREATE TABLE IF NOT EXISTS fleet_groups (
- id TEXT PRIMARY KEY,
- name TEXT NOT NULL,
- created_at_unix INTEGER NOT NULL
+CREATE TABLE agent_fallback_state (
+ agent_id TEXT PRIMARY KEY,
+ entered_at_unix INTEGER NOT NULL,
+ FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE
+);
+
+CREATE TABLE agent_revocations (
+ agent_id TEXT PRIMARY KEY,
+ revoked_at_unix INTEGER NOT NULL,
+ cert_expires_at_unix INTEGER NOT NULL
);
-CREATE TABLE IF NOT EXISTS agents (
+CREATE TABLE "agents" (
id TEXT PRIMARY KEY,
node_name TEXT NOT NULL,
fleet_group_id TEXT,
@@ -32,55 +44,322 @@ CREATE TABLE IF NOT EXISTS agents (
read_only INTEGER NOT NULL DEFAULT 0,
last_seen_at_unix INTEGER NOT NULL,
created_at_unix INTEGER NOT NULL DEFAULT 0,
+ cert_issued_at_unix INTEGER,
+ cert_expires_at_unix INTEGER,
+ cert_serial TEXT NOT NULL DEFAULT '',
+ transport_mode TEXT NOT NULL DEFAULT 'inbound'
+ CHECK (transport_mode IN ('inbound', 'outbound')),
+ dial_address TEXT,
+ bootstrap_state TEXT NOT NULL DEFAULT 'active'
+ CHECK (bootstrap_state IN ('pending', 'active', 'expired', 'revoked')),
+ bootstrap_token_hash BLOB,
+ bootstrap_expires_at INTEGER,
+ cert_spki_sha256 BLOB NOT NULL DEFAULT x''
+ CHECK (length(cert_spki_sha256) IN (0, 32)),
FOREIGN KEY (fleet_group_id) REFERENCES fleet_groups (id)
);
-CREATE TABLE IF NOT EXISTS telemt_instances (
+CREATE TABLE "audit_events" (
id TEXT PRIMARY KEY,
+ actor_id TEXT NOT NULL,
+ action TEXT NOT NULL,
+ target_id TEXT NOT NULL,
+ created_at_unix INTEGER NOT NULL,
+ details TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(details)),
+ prev_hash TEXT NOT NULL DEFAULT '',
+ event_hash TEXT NOT NULL DEFAULT ''
+);
+
+CREATE TABLE certificate_authority (
+ scope TEXT PRIMARY KEY,
+ ca_pem TEXT NOT NULL,
+ private_key_pem TEXT NOT NULL,
+ updated_at_unix INTEGER NOT NULL
+);
+
+CREATE TABLE "client_assignments" (
+ id TEXT PRIMARY KEY,
+ client_id TEXT NOT NULL,
+ target_type TEXT NOT NULL,
+ fleet_group_id TEXT,
+ agent_id TEXT,
+ created_at_unix INTEGER NOT NULL,
+ FOREIGN KEY (client_id) REFERENCES clients (id) ON DELETE CASCADE,
+ FOREIGN KEY (fleet_group_id) REFERENCES fleet_groups (id) ON DELETE SET NULL,
+ FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE SET NULL
+);
+
+CREATE TABLE "client_deployments" (
+ client_id TEXT NOT NULL,
agent_id TEXT NOT NULL,
+ desired_operation TEXT NOT NULL,
+ status TEXT NOT NULL,
+ last_error TEXT NOT NULL DEFAULT '',
+ last_applied_at_unix INTEGER,
+ updated_at_unix INTEGER NOT NULL,
+ connection_links TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(connection_links)),
+ last_reset_epoch_secs INTEGER NOT NULL DEFAULT 0,
+ link_diagnostic TEXT NOT NULL DEFAULT '',
+ PRIMARY KEY (client_id, agent_id),
+ FOREIGN KEY (client_id) REFERENCES clients (id) ON DELETE CASCADE,
+ FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE
+);
+
+CREATE TABLE client_ip_history (
+ agent_id TEXT NOT NULL,
+ client_id TEXT NOT NULL,
+ ip_address TEXT NOT NULL,
+ first_seen_unix INTEGER NOT NULL,
+ last_seen_unix INTEGER NOT NULL,
+ PRIMARY KEY (agent_id, client_id, ip_address)
+);
+
+CREATE TABLE client_usage (
+ client_id TEXT NOT NULL,
+ agent_id TEXT NOT NULL,
+ traffic_used_bytes INTEGER NOT NULL DEFAULT 0,
+ unique_ips_used INTEGER NOT NULL DEFAULT 0,
+ active_tcp_conns INTEGER NOT NULL DEFAULT 0,
+ active_unique_ips INTEGER NOT NULL DEFAULT 0,
+ observed_at_unix INTEGER NOT NULL, quota_used_bytes INTEGER NOT NULL DEFAULT 0, quota_last_reset_unix INTEGER NOT NULL DEFAULT 0, agent_boot_id TEXT NOT NULL DEFAULT '', last_total_bytes INTEGER NOT NULL DEFAULT 0,
+ PRIMARY KEY (client_id, agent_id),
+ FOREIGN KEY (client_id) REFERENCES clients (id) ON DELETE CASCADE,
+ FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE
+);
+
+CREATE TABLE clients (
+ id TEXT PRIMARY KEY,
name TEXT NOT NULL,
- version TEXT NOT NULL DEFAULT '',
- config_fingerprint TEXT NOT NULL DEFAULT '',
- connected_users INTEGER NOT NULL DEFAULT 0,
- read_only INTEGER NOT NULL DEFAULT 0,
+ secret_ciphertext TEXT NOT NULL,
+ user_ad_tag TEXT NOT NULL,
+ enabled INTEGER NOT NULL DEFAULT 1,
+ max_tcp_conns INTEGER NOT NULL DEFAULT 0,
+ max_unique_ips INTEGER NOT NULL DEFAULT 0,
+ data_quota_bytes INTEGER NOT NULL DEFAULT 0,
+ expiration_rfc3339 TEXT NOT NULL DEFAULT '',
+ created_at_unix INTEGER NOT NULL,
+ updated_at_unix INTEGER NOT NULL,
+ deleted_at_unix INTEGER
+, subscription_token TEXT);
+
+CREATE TABLE config_apply_batch_targets (
+ batch_id TEXT NOT NULL REFERENCES config_apply_batches (id) ON DELETE CASCADE,
+ agent_id TEXT NOT NULL,
+ wave_index INTEGER NOT NULL,
+ job_id TEXT NOT NULL DEFAULT '',
+ status TEXT NOT NULL CHECK (status IN ('pending', 'running', 'succeeded', 'failed', 'skipped')), message TEXT NOT NULL DEFAULT '',
+ PRIMARY KEY (batch_id, agent_id)
+);
+
+CREATE TABLE "config_apply_batches" (
+ id TEXT PRIMARY KEY,
+ fleet_group_id TEXT REFERENCES fleet_groups (id) ON DELETE CASCADE,
+ mode TEXT NOT NULL CHECK (mode IN ('all_at_once', 'rolling')),
+ wave_size INTEGER NOT NULL DEFAULT 1,
+ expected_revision TEXT NOT NULL DEFAULT '',
+ status TEXT NOT NULL CHECK (status IN ('running', 'succeeded', 'failed', 'halted')),
+ created_at_unix INTEGER NOT NULL,
+ updated_at_unix INTEGER NOT NULL
+);
+
+CREATE TABLE consumed_totp (
+ user_id TEXT NOT NULL,
+ code TEXT NOT NULL,
+ used_at_unix INTEGER NOT NULL,
+ PRIMARY KEY (user_id, code)
+);
+
+CREATE TABLE cp_secrets (
+ key TEXT PRIMARY KEY,
+ value BLOB NOT NULL,
+ updated_at_unix INTEGER NOT NULL
+);
+
+CREATE TABLE "discovered_clients" (
+ id TEXT PRIMARY KEY,
+ agent_id TEXT NOT NULL,
+ client_name TEXT NOT NULL,
+ secret TEXT NOT NULL DEFAULT '',
+ status TEXT NOT NULL DEFAULT 'pending_review' CHECK (status IN ('pending_review','adopted','ignored')),
+ total_octets INTEGER NOT NULL DEFAULT 0,
+ current_connections INTEGER NOT NULL DEFAULT 0,
+ active_unique_ips INTEGER NOT NULL DEFAULT 0,
+ max_tcp_conns INTEGER NOT NULL DEFAULT 0,
+ max_unique_ips INTEGER NOT NULL DEFAULT 0,
+ data_quota_bytes INTEGER NOT NULL DEFAULT 0,
+ expiration TEXT NOT NULL DEFAULT '',
+ discovered_at_unix INTEGER NOT NULL,
+ updated_at_unix INTEGER NOT NULL,
+ connection_links TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(connection_links)),
+ UNIQUE (agent_id, client_name),
+ FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE
+);
+
+CREATE TABLE enrollment_attempts (
+ id TEXT PRIMARY KEY,
+ token_id TEXT,
+ agent_id TEXT,
+ mode TEXT NOT NULL CHECK (mode IN ('inbound', 'outbound')),
+ client_addr TEXT,
+ request_id TEXT NOT NULL,
+ status TEXT NOT NULL CHECK (status IN ('in_progress', 'success', 'failed')),
+ error_code TEXT,
+ error_message TEXT,
+ started_at TIMESTAMP NOT NULL,
+ finished_at TIMESTAMP
+);
+
+CREATE TABLE "enrollment_events" (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ attempt_id TEXT NOT NULL REFERENCES enrollment_attempts (id) ON DELETE CASCADE,
+ ts TIMESTAMP NOT NULL,
+ step TEXT NOT NULL,
+ level TEXT NOT NULL CHECK (level IN ('info', 'warn', 'error')),
+ message TEXT,
+ fields_json TEXT CHECK (fields_json IS NULL OR json_valid(fields_json))
+);
+
+CREATE TABLE "enrollment_tokens" (
+ value TEXT PRIMARY KEY,
+ fleet_group_id TEXT,
+ issued_at_unix INTEGER NOT NULL,
+ expires_at_unix INTEGER NOT NULL,
+ consumed_at_unix INTEGER,
+ revoked_at_unix INTEGER,
+ FOREIGN KEY (fleet_group_id) REFERENCES fleet_groups (id) ON DELETE SET NULL
+);
+
+CREATE TABLE "fleet_group_integrations" (
+ id TEXT PRIMARY KEY,
+ fleet_group_id TEXT NOT NULL,
+ kind TEXT NOT NULL,
+ provider_id TEXT,
+ config TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(config)),
+ enabled INTEGER NOT NULL DEFAULT 0,
+ created_at_unix INTEGER NOT NULL,
updated_at_unix INTEGER NOT NULL,
- FOREIGN KEY (agent_id) REFERENCES agents (id)
+ FOREIGN KEY (fleet_group_id) REFERENCES fleet_groups (id) ON DELETE CASCADE,
+ FOREIGN KEY (provider_id) REFERENCES integration_providers (id) ON DELETE SET NULL,
+ UNIQUE (fleet_group_id, kind)
+);
+
+CREATE TABLE fleet_groups (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ created_at_unix INTEGER NOT NULL
+, label TEXT NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '', updated_at_unix INTEGER NOT NULL DEFAULT 0);
+
+CREATE TABLE "integration_providers" (
+ id TEXT PRIMARY KEY,
+ kind TEXT NOT NULL,
+ label TEXT NOT NULL DEFAULT '',
+ -- Permissive: config is either plain JSON or a vault-sealed
+ -- "PVS1:"/"PVS2:"/"PVS3:" ciphertext string (see file header note).
+ config TEXT NOT NULL DEFAULT '{}'
+ CHECK (json_valid(config) OR config LIKE 'PVS_:%'),
+ created_at_unix INTEGER NOT NULL,
+ updated_at_unix INTEGER NOT NULL
+);
+
+CREATE TABLE "job_targets" (
+ job_id TEXT NOT NULL,
+ agent_id TEXT NOT NULL,
+ status TEXT NOT NULL CHECK (status IN ('queued','sent','acknowledged','succeeded','failed','expired')),
+ result_text TEXT NOT NULL DEFAULT '',
+ result_json TEXT NOT NULL DEFAULT '',
+ updated_at_unix INTEGER NOT NULL,
+ PRIMARY KEY (job_id, agent_id),
+ FOREIGN KEY (job_id) REFERENCES jobs (id)
+);
+
+CREATE TABLE "jobs" (
+ id TEXT PRIMARY KEY,
+ action TEXT NOT NULL,
+ actor_id TEXT NOT NULL,
+ status TEXT NOT NULL CHECK (status IN ('queued','running','succeeded','failed','expired','partial')),
+ created_at_unix INTEGER NOT NULL,
+ ttl_nanos INTEGER NOT NULL,
+ idempotency_key TEXT NOT NULL UNIQUE,
+ payload_json TEXT NOT NULL DEFAULT ''
+ CHECK (payload_json = '' OR json_valid(payload_json))
+);
+
+CREATE TABLE login_lockouts (
+ username TEXT PRIMARY KEY,
+ failures INTEGER NOT NULL DEFAULT 0,
+ locked_at_unix INTEGER,
+ updated_at_unix INTEGER NOT NULL
+);
+
+CREATE TABLE "metric_snapshots" (
+ id TEXT PRIMARY KEY,
+ agent_id TEXT NOT NULL,
+ instance_id TEXT NOT NULL DEFAULT '',
+ captured_at_unix INTEGER NOT NULL,
+ "values" TEXT NOT NULL CHECK (json_valid("values")),
+ FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE
+);
+
+CREATE TABLE "panel_settings" (
+ scope TEXT PRIMARY KEY,
+ http_public_url TEXT NOT NULL DEFAULT '',
+ grpc_public_endpoint TEXT NOT NULL DEFAULT '',
+ password_min_length INTEGER NOT NULL DEFAULT 10
+ CHECK (password_min_length >= 8 AND password_min_length <= 128),
+ retention_json TEXT NOT NULL DEFAULT '',
+ geoip_json TEXT NOT NULL DEFAULT '',
+ geoip_state_json TEXT NOT NULL DEFAULT '',
+ updated_at_unix INTEGER NOT NULL
);
-CREATE TABLE IF NOT EXISTS telemt_runtime_current (
+CREATE TABLE runtime_settings (
+ name TEXT PRIMARY KEY,
+ value_json TEXT NOT NULL,
+ updated_at INTEGER NOT NULL,
+ updated_by TEXT NOT NULL DEFAULT ''
+);
+
+CREATE TABLE "sessions" (
+ id TEXT PRIMARY KEY,
+ user_id TEXT NOT NULL,
+ created_at_unix INTEGER NOT NULL, last_seen_at_unix INTEGER NOT NULL DEFAULT 0,
+ FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
+);
+
+CREATE TABLE telemt_diagnostics_current (
agent_id TEXT PRIMARY KEY,
observed_at_unix INTEGER NOT NULL,
state TEXT NOT NULL DEFAULT '',
state_reason TEXT NOT NULL DEFAULT '',
+ system_info_json TEXT NOT NULL DEFAULT '{}',
+ effective_limits_json TEXT NOT NULL DEFAULT '{}',
+ security_posture_json TEXT NOT NULL DEFAULT '{}',
+ minimal_all_json TEXT NOT NULL DEFAULT '{}',
+ me_pool_json TEXT NOT NULL DEFAULT '{}',
+ dcs_json TEXT NOT NULL DEFAULT '{}',
+ FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE
+);
+
+CREATE TABLE "telemt_instances" (
+ id TEXT PRIMARY KEY,
+ agent_id TEXT NOT NULL,
+ name TEXT NOT NULL,
+ version TEXT NOT NULL DEFAULT '',
+ config_fingerprint TEXT NOT NULL DEFAULT '',
+ connections INTEGER NOT NULL DEFAULT 0,
read_only INTEGER NOT NULL DEFAULT 0,
- accepting_new_connections INTEGER NOT NULL DEFAULT 0,
- me_runtime_ready INTEGER NOT NULL DEFAULT 0,
- me2dc_fallback_enabled INTEGER NOT NULL DEFAULT 0,
- use_middle_proxy INTEGER NOT NULL DEFAULT 0,
- startup_status TEXT NOT NULL DEFAULT '',
- startup_stage TEXT NOT NULL DEFAULT '',
- startup_progress_pct REAL NOT NULL DEFAULT 0,
- initialization_status TEXT NOT NULL DEFAULT '',
- degraded INTEGER NOT NULL DEFAULT 0,
- initialization_stage TEXT NOT NULL DEFAULT '',
- initialization_progress_pct REAL NOT NULL DEFAULT 0,
- transport_mode TEXT NOT NULL DEFAULT '',
- current_connections INTEGER NOT NULL DEFAULT 0,
- current_connections_me INTEGER NOT NULL DEFAULT 0,
- current_connections_direct INTEGER NOT NULL DEFAULT 0,
- active_users INTEGER NOT NULL DEFAULT 0,
- uptime_seconds REAL NOT NULL DEFAULT 0,
- connections_total INTEGER NOT NULL DEFAULT 0,
- connections_bad_total INTEGER NOT NULL DEFAULT 0,
- handshake_timeouts_total INTEGER NOT NULL DEFAULT 0,
- configured_users INTEGER NOT NULL DEFAULT 0,
- dc_coverage_pct REAL NOT NULL DEFAULT 0,
- healthy_upstreams INTEGER NOT NULL DEFAULT 0,
- total_upstreams INTEGER NOT NULL DEFAULT 0,
+ updated_at_unix INTEGER NOT NULL,
FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE
);
-CREATE TABLE IF NOT EXISTS telemt_runtime_dcs_current (
+CREATE TABLE telemt_runtime_current (
+ agent_id TEXT PRIMARY KEY,
+ observed_at_unix INTEGER NOT NULL,
+ runtime_json TEXT NOT NULL DEFAULT '',
+ FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE
+);
+
+CREATE TABLE telemt_runtime_dcs_current (
agent_id TEXT NOT NULL,
dc INTEGER NOT NULL,
observed_at_unix INTEGER NOT NULL,
@@ -95,23 +374,7 @@ CREATE TABLE IF NOT EXISTS telemt_runtime_dcs_current (
FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE
);
-CREATE TABLE IF NOT EXISTS telemt_runtime_upstreams_current (
- agent_id TEXT NOT NULL,
- upstream_id INTEGER NOT NULL,
- observed_at_unix INTEGER NOT NULL,
- route_kind TEXT NOT NULL DEFAULT '',
- address TEXT NOT NULL DEFAULT '',
- healthy INTEGER NOT NULL DEFAULT 0,
- fails INTEGER NOT NULL DEFAULT 0,
- effective_latency_ms REAL NOT NULL DEFAULT 0,
- PRIMARY KEY (agent_id, upstream_id),
- FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE
-);
-
--- Column name differs between drivers: SQLite uses timestamp_unix (INTEGER)
--- while Postgres uses timestamp_at (TIMESTAMPTZ). Query implementations in
--- each driver's telemetry.go must use the correct column name.
-CREATE TABLE IF NOT EXISTS telemt_runtime_events (
+CREATE TABLE telemt_runtime_events (
agent_id TEXT NOT NULL,
sequence INTEGER NOT NULL,
observed_at_unix INTEGER NOT NULL,
@@ -123,21 +386,20 @@ CREATE TABLE IF NOT EXISTS telemt_runtime_events (
FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE
);
-CREATE TABLE IF NOT EXISTS telemt_diagnostics_current (
- agent_id TEXT PRIMARY KEY,
+CREATE TABLE telemt_runtime_upstreams_current (
+ agent_id TEXT NOT NULL,
+ upstream_id INTEGER NOT NULL,
observed_at_unix INTEGER NOT NULL,
- state TEXT NOT NULL DEFAULT '',
- state_reason TEXT NOT NULL DEFAULT '',
- system_info_json TEXT NOT NULL DEFAULT '{}',
- effective_limits_json TEXT NOT NULL DEFAULT '{}',
- security_posture_json TEXT NOT NULL DEFAULT '{}',
- minimal_all_json TEXT NOT NULL DEFAULT '{}',
- me_pool_json TEXT NOT NULL DEFAULT '{}',
- dcs_json TEXT NOT NULL DEFAULT '{}',
+ route_kind TEXT NOT NULL DEFAULT '',
+ address TEXT NOT NULL DEFAULT '',
+ healthy INTEGER NOT NULL DEFAULT 0,
+ fails INTEGER NOT NULL DEFAULT 0,
+ effective_latency_ms REAL NOT NULL DEFAULT 0,
+ PRIMARY KEY (agent_id, upstream_id),
FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE
);
-CREATE TABLE IF NOT EXISTS telemt_security_inventory_current (
+CREATE TABLE telemt_security_inventory_current (
agent_id TEXT PRIMARY KEY,
observed_at_unix INTEGER NOT NULL,
state TEXT NOT NULL DEFAULT '',
@@ -148,153 +410,230 @@ CREATE TABLE IF NOT EXISTS telemt_security_inventory_current (
FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE
);
-CREATE TABLE IF NOT EXISTS telemt_detail_boosts (
- agent_id TEXT PRIMARY KEY,
- expires_at_unix INTEGER NOT NULL,
- updated_at_unix INTEGER NOT NULL,
- FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE
-);
-
-CREATE TABLE IF NOT EXISTS jobs (
- id TEXT PRIMARY KEY,
- action TEXT NOT NULL,
- actor_id TEXT NOT NULL,
- status TEXT NOT NULL,
- created_at_unix INTEGER NOT NULL,
- ttl_nanos INTEGER NOT NULL,
- idempotency_key TEXT NOT NULL UNIQUE,
- payload_json TEXT NOT NULL DEFAULT ''
+CREATE TABLE ts_dc_health (
+ agent_id TEXT NOT NULL,
+ captured_at_unix INTEGER NOT NULL,
+ dc INTEGER NOT NULL,
+ coverage_pct_avg REAL NOT NULL DEFAULT 0,
+ coverage_pct_min REAL NOT NULL DEFAULT 0,
+ rtt_ms_avg REAL NOT NULL DEFAULT 0,
+ rtt_ms_max REAL NOT NULL DEFAULT 0,
+ alive_writers_min INTEGER NOT NULL DEFAULT 0,
+ required_writers INTEGER NOT NULL DEFAULT 0,
+ load_max INTEGER NOT NULL DEFAULT 0,
+ sample_count INTEGER NOT NULL DEFAULT 1,
+ PRIMARY KEY (agent_id, dc, captured_at_unix)
);
-CREATE TABLE IF NOT EXISTS job_targets (
- job_id TEXT NOT NULL,
- agent_id TEXT NOT NULL,
- status TEXT NOT NULL,
- result_text TEXT NOT NULL DEFAULT '',
- result_json TEXT NOT NULL DEFAULT '',
- updated_at_unix INTEGER NOT NULL,
- PRIMARY KEY (job_id, agent_id),
- FOREIGN KEY (job_id) REFERENCES jobs (id)
+CREATE TABLE ts_server_load (
+ agent_id TEXT NOT NULL,
+ captured_at_unix INTEGER NOT NULL,
+ cpu_pct_avg REAL NOT NULL DEFAULT 0,
+ cpu_pct_max REAL NOT NULL DEFAULT 0,
+ mem_pct_avg REAL NOT NULL DEFAULT 0,
+ mem_pct_max REAL NOT NULL DEFAULT 0,
+ disk_pct_avg REAL NOT NULL DEFAULT 0,
+ disk_pct_max REAL NOT NULL DEFAULT 0,
+ load_1m REAL NOT NULL DEFAULT 0,
+ load_5m REAL NOT NULL DEFAULT 0,
+ load_15m REAL NOT NULL DEFAULT 0,
+ connections_avg INTEGER NOT NULL DEFAULT 0,
+ connections_max INTEGER NOT NULL DEFAULT 0,
+ connections_me_avg INTEGER NOT NULL DEFAULT 0,
+ connections_direct_avg INTEGER NOT NULL DEFAULT 0,
+ active_users_avg INTEGER NOT NULL DEFAULT 0,
+ active_users_max INTEGER NOT NULL DEFAULT 0,
+ connections_total INTEGER NOT NULL DEFAULT 0,
+ connections_bad_total INTEGER NOT NULL DEFAULT 0,
+ handshake_timeouts_total INTEGER NOT NULL DEFAULT 0,
+ dc_coverage_min_pct REAL NOT NULL DEFAULT 0,
+ dc_coverage_avg_pct REAL NOT NULL DEFAULT 0,
+ healthy_upstreams INTEGER NOT NULL DEFAULT 0,
+ total_upstreams INTEGER NOT NULL DEFAULT 0,
+ net_bytes_sent INTEGER NOT NULL DEFAULT 0,
+ net_bytes_recv INTEGER NOT NULL DEFAULT 0,
+ sample_count INTEGER NOT NULL DEFAULT 1,
+ PRIMARY KEY (agent_id, captured_at_unix)
);
-CREATE TABLE IF NOT EXISTS audit_events (
- id TEXT PRIMARY KEY,
- actor_id TEXT NOT NULL,
- action TEXT NOT NULL,
- target_id TEXT NOT NULL,
- created_at_unix INTEGER NOT NULL,
- details_json TEXT NOT NULL DEFAULT '{}'
+CREATE TABLE ts_server_load_hourly (
+ agent_id TEXT NOT NULL,
+ bucket_hour_unix INTEGER NOT NULL,
+ cpu_pct_avg REAL,
+ cpu_pct_max REAL,
+ mem_pct_avg REAL,
+ mem_pct_max REAL,
+ connections_avg REAL,
+ connections_max INTEGER,
+ active_users_avg REAL,
+ active_users_max INTEGER,
+ dc_coverage_min REAL,
+ dc_coverage_avg REAL,
+ sample_count INTEGER NOT NULL DEFAULT 0,
+ PRIMARY KEY (agent_id, bucket_hour_unix)
);
-CREATE TABLE IF NOT EXISTS metric_snapshots (
- id TEXT PRIMARY KEY,
- agent_id TEXT NOT NULL,
- instance_id TEXT NOT NULL DEFAULT '',
- captured_at_unix INTEGER NOT NULL,
- values_json TEXT NOT NULL
+CREATE TABLE update_config (
+ key TEXT PRIMARY KEY,
+ value TEXT NOT NULL
);
-CREATE TABLE IF NOT EXISTS enrollment_tokens (
- value TEXT PRIMARY KEY,
- fleet_group_id TEXT,
- issued_at_unix INTEGER NOT NULL,
- expires_at_unix INTEGER NOT NULL,
- consumed_at_unix INTEGER,
- revoked_at_unix INTEGER
+CREATE TABLE user_appearance (
+ user_id TEXT PRIMARY KEY,
+ theme TEXT NOT NULL DEFAULT 'system',
+ density TEXT NOT NULL DEFAULT 'comfortable',
+ help_mode TEXT NOT NULL DEFAULT 'basic',
+ updated_at_unix INTEGER NOT NULL DEFAULT 0,
+ FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
-CREATE TABLE IF NOT EXISTS agent_certificate_recovery_grants (
- agent_id TEXT PRIMARY KEY,
- issued_by TEXT NOT NULL,
- issued_at_unix INTEGER NOT NULL,
- expires_at_unix INTEGER NOT NULL,
- used_at_unix INTEGER,
- revoked_at_unix INTEGER,
- FOREIGN KEY (agent_id) REFERENCES agents (id)
+CREATE TABLE user_fleet_group_scopes (
+ user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
+ fleet_group_id TEXT NOT NULL REFERENCES fleet_groups (id) ON DELETE CASCADE,
+ granted_at_unix INTEGER NOT NULL DEFAULT 0,
+ granted_by TEXT NOT NULL DEFAULT '',
+ PRIMARY KEY (user_id, fleet_group_id)
);
-CREATE TABLE IF NOT EXISTS clients (
+CREATE TABLE users (
id TEXT PRIMARY KEY,
- name TEXT NOT NULL,
- secret_ciphertext TEXT NOT NULL,
- user_ad_tag TEXT NOT NULL,
- enabled INTEGER NOT NULL DEFAULT 1,
- max_tcp_conns INTEGER NOT NULL DEFAULT 0,
- max_unique_ips INTEGER NOT NULL DEFAULT 0,
- data_quota_bytes INTEGER NOT NULL DEFAULT 0,
- expiration_rfc3339 TEXT NOT NULL DEFAULT '',
- created_at_unix INTEGER NOT NULL,
- updated_at_unix INTEGER NOT NULL,
- deleted_at_unix INTEGER
+ username TEXT NOT NULL UNIQUE,
+ password_hash TEXT NOT NULL,
+ role TEXT NOT NULL,
+ totp_enabled INTEGER NOT NULL DEFAULT 0,
+ totp_secret TEXT NOT NULL DEFAULT '',
+ created_at_unix INTEGER NOT NULL
);
-CREATE TABLE IF NOT EXISTS client_assignments (
- id TEXT PRIMARY KEY,
- client_id TEXT NOT NULL,
- target_type TEXT NOT NULL,
- fleet_group_id TEXT,
- agent_id TEXT,
- created_at_unix INTEGER NOT NULL,
- FOREIGN KEY (client_id) REFERENCES clients (id),
- FOREIGN KEY (fleet_group_id) REFERENCES fleet_groups (id),
- FOREIGN KEY (agent_id) REFERENCES agents (id)
+CREATE TABLE webhook_endpoints (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL UNIQUE,
+ url TEXT NOT NULL,
+ secret_ciphertext TEXT NOT NULL,
+ event_filter TEXT NOT NULL DEFAULT '',
+ allow_private INTEGER NOT NULL DEFAULT 0 CHECK (allow_private IN (0, 1)),
+ enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-CREATE TABLE IF NOT EXISTS client_deployments (
- client_id TEXT NOT NULL,
- agent_id TEXT NOT NULL,
- desired_operation TEXT NOT NULL,
- status TEXT NOT NULL,
- last_error TEXT NOT NULL DEFAULT '',
- connection_link TEXT NOT NULL DEFAULT '',
- last_applied_at_unix INTEGER,
- updated_at_unix INTEGER NOT NULL,
- PRIMARY KEY (client_id, agent_id),
- FOREIGN KEY (client_id) REFERENCES clients (id),
- FOREIGN KEY (agent_id) REFERENCES agents (id)
+CREATE TABLE "webhook_outbox" (
+ id TEXT PRIMARY KEY,
+ endpoint_id TEXT NOT NULL REFERENCES webhook_endpoints (id) ON DELETE CASCADE,
+ event_action TEXT NOT NULL,
+ payload TEXT NOT NULL CHECK (json_valid(payload)),
+ attempt INTEGER NOT NULL DEFAULT 0,
+ next_attempt_at TIMESTAMP NOT NULL,
+ last_error TEXT NOT NULL DEFAULT '',
+ dead INTEGER NOT NULL DEFAULT 0 CHECK (dead IN (0, 1)),
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ delivered_at TIMESTAMP
);
-CREATE TABLE IF NOT EXISTS panel_settings (
- scope TEXT PRIMARY KEY,
- http_public_url TEXT NOT NULL DEFAULT '',
- http_root_path TEXT NOT NULL DEFAULT '',
- grpc_public_endpoint TEXT NOT NULL DEFAULT '',
- http_listen_address TEXT NOT NULL DEFAULT '',
- grpc_listen_address TEXT NOT NULL DEFAULT '',
- tls_mode TEXT NOT NULL DEFAULT '',
- tls_cert_file TEXT NOT NULL DEFAULT '',
- tls_key_file TEXT NOT NULL DEFAULT '',
- updated_at_unix INTEGER NOT NULL
-);
+CREATE UNIQUE INDEX clients_subscription_token_key
+ ON clients (subscription_token);
-CREATE TABLE IF NOT EXISTS certificate_authority (
- scope TEXT PRIMARY KEY,
- ca_pem TEXT NOT NULL,
- private_key_pem TEXT NOT NULL,
- updated_at_unix INTEGER NOT NULL
-);
+CREATE UNIQUE INDEX fleet_groups_name_unique
+ ON fleet_groups (name);
+
+CREATE INDEX idx_agent_fallback_state_entered_at
+ ON agent_fallback_state (entered_at_unix);
+
+CREATE INDEX idx_agent_revocations_cert_expires_at_unix
+ ON agent_revocations(cert_expires_at_unix);
+
+CREATE INDEX idx_agents_cert_spki_sha256
+ ON agents (cert_spki_sha256)
+ WHERE length(cert_spki_sha256) > 0;
+
+CREATE INDEX idx_agents_fleet_group_id ON agents (fleet_group_id);
+
+CREATE INDEX idx_agents_last_seen_at ON agents (last_seen_at_unix);
+
+CREATE INDEX idx_agents_transport_mode ON agents(transport_mode);
+
+CREATE INDEX idx_audit_events_chain_walk ON audit_events (created_at_unix, id);
+
+CREATE INDEX idx_audit_events_created_at ON audit_events (created_at_unix);
+
+CREATE INDEX idx_client_assignments_client_id ON client_assignments (client_id);
+
+CREATE INDEX idx_client_deployments_client_id ON client_deployments (client_id);
+
+CREATE INDEX idx_client_ip_client ON client_ip_history (client_id, last_seen_unix DESC);
+
+CREATE INDEX idx_client_ip_client_addr ON client_ip_history (client_id, ip_address);
+
+CREATE INDEX idx_client_ip_last_seen ON client_ip_history (last_seen_unix);
+
+CREATE INDEX idx_client_usage_agent_id
+ ON client_usage (agent_id);
+
+CREATE INDEX idx_config_apply_batch_targets_batch_wave
+ ON config_apply_batch_targets (batch_id, wave_index);
+
+CREATE INDEX idx_config_apply_batches_status
+ ON config_apply_batches (status);
+
+CREATE INDEX idx_consumed_totp_used_at ON consumed_totp(used_at_unix);
+
+CREATE INDEX idx_discovered_clients_agent_id ON discovered_clients (agent_id);
+
+CREATE UNIQUE INDEX idx_discovered_clients_pending_unique
+ ON discovered_clients (agent_id, client_name)
+ WHERE status = 'pending_review';
+
+CREATE INDEX idx_enrollment_attempts_agent ON enrollment_attempts(agent_id);
+
+CREATE INDEX idx_enrollment_attempts_started ON enrollment_attempts(started_at);
+
+CREATE INDEX idx_enrollment_attempts_token ON enrollment_attempts(token_id);
+
+CREATE INDEX idx_enrollment_events_attempt ON enrollment_events (attempt_id, ts);
+
+CREATE INDEX idx_enrollment_tokens_fleet_group_id ON enrollment_tokens (fleet_group_id);
+
+CREATE INDEX idx_fleet_group_integrations_fleet_group_id ON fleet_group_integrations (fleet_group_id);
+
+CREATE INDEX idx_fleet_group_integrations_kind ON fleet_group_integrations (kind);
+
+CREATE INDEX idx_integration_providers_kind ON integration_providers (kind);
+
+CREATE INDEX idx_job_targets_agent_id ON job_targets (agent_id);
+
+CREATE INDEX idx_jobs_actor_id ON jobs (actor_id);
+
+CREATE INDEX idx_jobs_created_at ON jobs (created_at_unix);
+
+CREATE INDEX idx_jobs_status ON jobs (status);
+
+CREATE INDEX idx_login_lockouts_locked_at_unix
+ ON login_lockouts(locked_at_unix);
+
+CREATE INDEX idx_metric_snapshots_agent_captured ON metric_snapshots (agent_id, captured_at_unix);
+
+CREATE INDEX idx_metric_snapshots_captured_at ON metric_snapshots (captured_at_unix);
+
+CREATE INDEX idx_sessions_created_at_unix ON sessions (created_at_unix);
+
+CREATE INDEX idx_sessions_user_id ON sessions (user_id);
+
+CREATE INDEX idx_telemt_instances_agent_id ON telemt_instances (agent_id);
+
+CREATE INDEX idx_ts_dc_health_time ON ts_dc_health (agent_id, captured_at_unix DESC);
+
+CREATE INDEX idx_ts_server_load_time ON ts_server_load (agent_id, captured_at_unix DESC);
+
+CREATE INDEX idx_user_fleet_group_scopes_fleet_group_id
+ ON user_fleet_group_scopes (fleet_group_id);
+
+CREATE INDEX idx_user_fleet_group_scopes_user_id
+ ON user_fleet_group_scopes (user_id);
+
+CREATE INDEX idx_webhook_outbox_ready
+ ON webhook_outbox (next_attempt_at)
+ WHERE dead = 0 AND delivered_at IS NULL;
-- +goose Down
-DROP TABLE IF EXISTS certificate_authority;
-DROP TABLE IF EXISTS panel_settings;
-DROP TABLE IF EXISTS client_deployments;
-DROP TABLE IF EXISTS client_assignments;
-DROP TABLE IF EXISTS clients;
-DROP TABLE IF EXISTS agent_certificate_recovery_grants;
-DROP TABLE IF EXISTS enrollment_tokens;
-DROP TABLE IF EXISTS metric_snapshots;
-DROP TABLE IF EXISTS audit_events;
-DROP TABLE IF EXISTS job_targets;
-DROP TABLE IF EXISTS jobs;
-DROP TABLE IF EXISTS telemt_detail_boosts;
-DROP TABLE IF EXISTS telemt_security_inventory_current;
-DROP TABLE IF EXISTS telemt_diagnostics_current;
-DROP TABLE IF EXISTS telemt_runtime_events;
-DROP TABLE IF EXISTS telemt_runtime_upstreams_current;
-DROP TABLE IF EXISTS telemt_runtime_dcs_current;
-DROP TABLE IF EXISTS telemt_runtime_current;
-DROP TABLE IF EXISTS telemt_instances;
-DROP TABLE IF EXISTS agents;
-DROP TABLE IF EXISTS user_appearance;
-DROP TABLE IF EXISTS users;
-DROP TABLE IF EXISTS fleet_groups;
+-- Squash-init: даунгрейда некуда — no-op.
+SELECT 1;
diff --git a/db/migrations/sqlite/0002_discovered_clients.sql b/db/migrations/sqlite/0002_discovered_clients.sql
deleted file mode 100644
index da44e33e..00000000
--- a/db/migrations/sqlite/0002_discovered_clients.sql
+++ /dev/null
@@ -1,23 +0,0 @@
--- +goose Up
-CREATE TABLE IF NOT EXISTS discovered_clients (
- id TEXT PRIMARY KEY,
- agent_id TEXT NOT NULL,
- client_name TEXT NOT NULL,
- secret TEXT NOT NULL DEFAULT '',
- status TEXT NOT NULL DEFAULT 'pending_review',
- total_octets INTEGER NOT NULL DEFAULT 0,
- current_connections INTEGER NOT NULL DEFAULT 0,
- active_unique_ips INTEGER NOT NULL DEFAULT 0,
- connection_link TEXT NOT NULL DEFAULT '',
- max_tcp_conns INTEGER NOT NULL DEFAULT 0,
- max_unique_ips INTEGER NOT NULL DEFAULT 0,
- data_quota_bytes INTEGER NOT NULL DEFAULT 0,
- expiration TEXT NOT NULL DEFAULT '',
- discovered_at_unix INTEGER NOT NULL,
- updated_at_unix INTEGER NOT NULL,
- UNIQUE (agent_id, client_name),
- FOREIGN KEY (agent_id) REFERENCES agents (id)
-);
-
--- +goose Down
-DROP TABLE IF EXISTS discovered_clients;
diff --git a/db/migrations/sqlite/0003_agent_cert_dates.sql b/db/migrations/sqlite/0003_agent_cert_dates.sql
deleted file mode 100644
index 9fd5b040..00000000
--- a/db/migrations/sqlite/0003_agent_cert_dates.sql
+++ /dev/null
@@ -1,15 +0,0 @@
--- +goose Up
--- NOTE: SQLite's ALTER TABLE ADD COLUMN has no "IF NOT EXISTS" clause.
--- Goose's version tracking ensures this migration only runs once per database,
--- so an unconditional ADD COLUMN is safe here — the previous hand-rolled
--- ensureAgentsCertIssuedAtColumn helpers became necessary only because the
--- ad-hoc migrator had no way to know whether the column was already applied.
-ALTER TABLE agents ADD COLUMN cert_issued_at_unix INTEGER;
-ALTER TABLE agents ADD COLUMN cert_expires_at_unix INTEGER;
-
--- +goose Down
--- SQLite supports DROP COLUMN since 3.35 (modernc.org/sqlite ships 3.47+),
--- but the operation rewrites the table; for a pre-3.35 runtime this Down
--- would need a table-rename approach. Best-effort on modern SQLite.
-ALTER TABLE agents DROP COLUMN cert_expires_at_unix;
-ALTER TABLE agents DROP COLUMN cert_issued_at_unix;
diff --git a/db/migrations/sqlite/0004_sessions.sql b/db/migrations/sqlite/0004_sessions.sql
deleted file mode 100644
index 1d46a365..00000000
--- a/db/migrations/sqlite/0004_sessions.sql
+++ /dev/null
@@ -1,14 +0,0 @@
--- +goose Up
-CREATE TABLE IF NOT EXISTS sessions (
- id TEXT PRIMARY KEY,
- user_id TEXT NOT NULL,
- created_at_unix INTEGER NOT NULL
-);
-
-CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
-CREATE INDEX IF NOT EXISTS idx_sessions_created_at_unix ON sessions(created_at_unix);
-
--- +goose Down
-DROP INDEX IF EXISTS idx_sessions_created_at_unix;
-DROP INDEX IF EXISTS idx_sessions_user_id;
-DROP TABLE IF EXISTS sessions;
diff --git a/db/migrations/sqlite/0005_agent_revocations.sql b/db/migrations/sqlite/0005_agent_revocations.sql
deleted file mode 100644
index 67e91a26..00000000
--- a/db/migrations/sqlite/0005_agent_revocations.sql
+++ /dev/null
@@ -1,15 +0,0 @@
--- +goose Up
--- See postgres/0005_agent_revocations.sql for rationale. SQLite uses INTEGER
--- unix timestamps to match the existing convention.
-CREATE TABLE IF NOT EXISTS agent_revocations (
- agent_id TEXT PRIMARY KEY,
- revoked_at_unix INTEGER NOT NULL,
- cert_expires_at_unix INTEGER NOT NULL
-);
-
-CREATE INDEX IF NOT EXISTS idx_agent_revocations_cert_expires_at_unix
- ON agent_revocations(cert_expires_at_unix);
-
--- +goose Down
-DROP INDEX IF EXISTS idx_agent_revocations_cert_expires_at_unix;
-DROP TABLE IF EXISTS agent_revocations;
diff --git a/db/migrations/sqlite/0006_timeseries_and_update_config.sql b/db/migrations/sqlite/0006_timeseries_and_update_config.sql
deleted file mode 100644
index 0135af09..00000000
--- a/db/migrations/sqlite/0006_timeseries_and_update_config.sql
+++ /dev/null
@@ -1,85 +0,0 @@
--- +goose Up
--- update_config stores internal feature-flag/kv state for the updater subsystem.
-CREATE TABLE IF NOT EXISTS update_config (
- key TEXT PRIMARY KEY,
- value TEXT NOT NULL
-);
-
-CREATE TABLE IF NOT EXISTS ts_server_load (
- agent_id TEXT NOT NULL,
- captured_at_unix INTEGER NOT NULL,
- cpu_pct_avg REAL NOT NULL DEFAULT 0,
- cpu_pct_max REAL NOT NULL DEFAULT 0,
- mem_pct_avg REAL NOT NULL DEFAULT 0,
- mem_pct_max REAL NOT NULL DEFAULT 0,
- disk_pct_avg REAL NOT NULL DEFAULT 0,
- disk_pct_max REAL NOT NULL DEFAULT 0,
- load_1m REAL NOT NULL DEFAULT 0,
- load_5m REAL NOT NULL DEFAULT 0,
- load_15m REAL NOT NULL DEFAULT 0,
- connections_avg INTEGER NOT NULL DEFAULT 0,
- connections_max INTEGER NOT NULL DEFAULT 0,
- connections_me_avg INTEGER NOT NULL DEFAULT 0,
- connections_direct_avg INTEGER NOT NULL DEFAULT 0,
- active_users_avg INTEGER NOT NULL DEFAULT 0,
- active_users_max INTEGER NOT NULL DEFAULT 0,
- connections_total INTEGER NOT NULL DEFAULT 0,
- connections_bad_total INTEGER NOT NULL DEFAULT 0,
- handshake_timeouts_total INTEGER NOT NULL DEFAULT 0,
- dc_coverage_min_pct REAL NOT NULL DEFAULT 0,
- dc_coverage_avg_pct REAL NOT NULL DEFAULT 0,
- healthy_upstreams INTEGER NOT NULL DEFAULT 0,
- total_upstreams INTEGER NOT NULL DEFAULT 0,
- net_bytes_sent INTEGER NOT NULL DEFAULT 0,
- net_bytes_recv INTEGER NOT NULL DEFAULT 0,
- sample_count INTEGER NOT NULL DEFAULT 1,
- PRIMARY KEY (agent_id, captured_at_unix)
-);
-
-CREATE TABLE IF NOT EXISTS ts_dc_health (
- agent_id TEXT NOT NULL,
- captured_at_unix INTEGER NOT NULL,
- dc INTEGER NOT NULL,
- coverage_pct_avg REAL NOT NULL DEFAULT 0,
- coverage_pct_min REAL NOT NULL DEFAULT 0,
- rtt_ms_avg REAL NOT NULL DEFAULT 0,
- rtt_ms_max REAL NOT NULL DEFAULT 0,
- alive_writers_min INTEGER NOT NULL DEFAULT 0,
- required_writers INTEGER NOT NULL DEFAULT 0,
- load_max INTEGER NOT NULL DEFAULT 0,
- sample_count INTEGER NOT NULL DEFAULT 1,
- PRIMARY KEY (agent_id, dc, captured_at_unix)
-);
-
-CREATE TABLE IF NOT EXISTS ts_server_load_hourly (
- agent_id TEXT NOT NULL,
- bucket_hour_unix INTEGER NOT NULL,
- cpu_pct_avg REAL,
- cpu_pct_max REAL,
- mem_pct_avg REAL,
- mem_pct_max REAL,
- connections_avg REAL,
- connections_max INTEGER,
- active_users_avg REAL,
- active_users_max INTEGER,
- dc_coverage_min REAL,
- dc_coverage_avg REAL,
- sample_count INTEGER NOT NULL DEFAULT 0,
- PRIMARY KEY (agent_id, bucket_hour_unix)
-);
-
-CREATE TABLE IF NOT EXISTS client_ip_history (
- agent_id TEXT NOT NULL,
- client_id TEXT NOT NULL,
- ip_address TEXT NOT NULL,
- first_seen_unix INTEGER NOT NULL,
- last_seen_unix INTEGER NOT NULL,
- PRIMARY KEY (agent_id, client_id, ip_address)
-);
-
--- +goose Down
-DROP TABLE IF EXISTS client_ip_history;
-DROP TABLE IF EXISTS ts_server_load_hourly;
-DROP TABLE IF EXISTS ts_dc_health;
-DROP TABLE IF EXISTS ts_server_load;
-DROP TABLE IF EXISTS update_config;
diff --git a/db/migrations/sqlite/0007_indexes.sql b/db/migrations/sqlite/0007_indexes.sql
deleted file mode 100644
index 4e02b762..00000000
--- a/db/migrations/sqlite/0007_indexes.sql
+++ /dev/null
@@ -1,31 +0,0 @@
--- +goose Up
-CREATE INDEX IF NOT EXISTS idx_agents_last_seen_at ON agents (last_seen_at_unix);
-CREATE INDEX IF NOT EXISTS idx_agents_fleet_group_id ON agents (fleet_group_id);
-CREATE INDEX IF NOT EXISTS idx_telemt_instances_agent_id ON telemt_instances (agent_id);
-CREATE INDEX IF NOT EXISTS idx_client_assignments_client_id ON client_assignments (client_id);
-CREATE INDEX IF NOT EXISTS idx_client_deployments_client_id ON client_deployments (client_id);
-CREATE INDEX IF NOT EXISTS idx_jobs_created_at ON jobs (created_at_unix);
-CREATE INDEX IF NOT EXISTS idx_audit_events_created_at ON audit_events (created_at_unix);
-CREATE INDEX IF NOT EXISTS idx_metric_snapshots_agent_captured ON metric_snapshots (agent_id, captured_at_unix);
-CREATE INDEX IF NOT EXISTS idx_discovered_clients_agent_id ON discovered_clients (agent_id);
-CREATE INDEX IF NOT EXISTS idx_ts_server_load_time ON ts_server_load (agent_id, captured_at_unix DESC);
-CREATE INDEX IF NOT EXISTS idx_ts_dc_health_time ON ts_dc_health (agent_id, captured_at_unix DESC);
-CREATE INDEX IF NOT EXISTS idx_client_ip_last_seen ON client_ip_history (last_seen_unix);
-CREATE INDEX IF NOT EXISTS idx_client_ip_client ON client_ip_history (client_id, last_seen_unix DESC);
-CREATE INDEX IF NOT EXISTS idx_client_ip_client_addr ON client_ip_history (client_id, ip_address);
-
--- +goose Down
-DROP INDEX IF EXISTS idx_client_ip_client_addr;
-DROP INDEX IF EXISTS idx_client_ip_client;
-DROP INDEX IF EXISTS idx_client_ip_last_seen;
-DROP INDEX IF EXISTS idx_ts_dc_health_time;
-DROP INDEX IF EXISTS idx_ts_server_load_time;
-DROP INDEX IF EXISTS idx_discovered_clients_agent_id;
-DROP INDEX IF EXISTS idx_metric_snapshots_agent_captured;
-DROP INDEX IF EXISTS idx_audit_events_created_at;
-DROP INDEX IF EXISTS idx_jobs_created_at;
-DROP INDEX IF EXISTS idx_client_deployments_client_id;
-DROP INDEX IF EXISTS idx_client_assignments_client_id;
-DROP INDEX IF EXISTS idx_telemt_instances_agent_id;
-DROP INDEX IF EXISTS idx_agents_fleet_group_id;
-DROP INDEX IF EXISTS idx_agents_last_seen_at;
diff --git a/db/migrations/sqlite/0008_missing_indexes.sql b/db/migrations/sqlite/0008_missing_indexes.sql
deleted file mode 100644
index 1921eb0c..00000000
--- a/db/migrations/sqlite/0008_missing_indexes.sql
+++ /dev/null
@@ -1,11 +0,0 @@
--- +goose Up
-CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs (status);
-CREATE INDEX IF NOT EXISTS idx_job_targets_agent_id ON job_targets (agent_id);
-CREATE INDEX IF NOT EXISTS idx_metric_snapshots_captured_at ON metric_snapshots (captured_at_unix);
-CREATE INDEX IF NOT EXISTS idx_enrollment_tokens_fleet_group_id ON enrollment_tokens (fleet_group_id);
-
--- +goose Down
-DROP INDEX IF EXISTS idx_enrollment_tokens_fleet_group_id;
-DROP INDEX IF EXISTS idx_metric_snapshots_captured_at;
-DROP INDEX IF EXISTS idx_job_targets_agent_id;
-DROP INDEX IF EXISTS idx_jobs_status;
diff --git a/db/migrations/sqlite/0009_retention_settings.sql b/db/migrations/sqlite/0009_retention_settings.sql
deleted file mode 100644
index b89f6385..00000000
--- a/db/migrations/sqlite/0009_retention_settings.sql
+++ /dev/null
@@ -1,35 +0,0 @@
--- +goose Up
--- P2-REL-03: persist retention settings across restarts.
--- Adds a retention_json column to panel_settings so the singleton
--- scope='panel' row also carries the operator-managed retention
--- configuration as an opaque JSON blob. Empty string means "not
--- yet set" and causes the control-plane to fall back to defaults.
--- SQLite's ALTER TABLE ADD COLUMN lacks IF NOT EXISTS; goose records
--- this version so it never runs twice on the same database.
-ALTER TABLE panel_settings
- ADD COLUMN retention_json TEXT NOT NULL DEFAULT '';
-
--- +goose Down
--- SQLite cannot DROP COLUMN on older versions; recreate the table
--- without retention_json to roll back. This Down path is used only
--- by tests / operator-initiated downgrades — production drops table
--- contents via 0001's Down.
-CREATE TABLE panel_settings_backup (
- scope TEXT PRIMARY KEY,
- http_public_url TEXT NOT NULL DEFAULT '',
- http_root_path TEXT NOT NULL DEFAULT '',
- grpc_public_endpoint TEXT NOT NULL DEFAULT '',
- http_listen_address TEXT NOT NULL DEFAULT '',
- grpc_listen_address TEXT NOT NULL DEFAULT '',
- tls_mode TEXT NOT NULL DEFAULT '',
- tls_cert_file TEXT NOT NULL DEFAULT '',
- tls_key_file TEXT NOT NULL DEFAULT '',
- updated_at_unix INTEGER NOT NULL
-);
-INSERT INTO panel_settings_backup
- SELECT scope, http_public_url, http_root_path, grpc_public_endpoint,
- http_listen_address, grpc_listen_address, tls_mode, tls_cert_file,
- tls_key_file, updated_at_unix
- FROM panel_settings;
-DROP TABLE panel_settings;
-ALTER TABLE panel_settings_backup RENAME TO panel_settings;
diff --git a/db/migrations/sqlite/0010_discovered_clients_pending_unique.sql b/db/migrations/sqlite/0010_discovered_clients_pending_unique.sql
deleted file mode 100644
index 6a01f63d..00000000
--- a/db/migrations/sqlite/0010_discovered_clients_pending_unique.sql
+++ /dev/null
@@ -1,13 +0,0 @@
--- +goose Up
--- P2-LOG-02 (L-10 / M-C4): belt-and-suspenders guard against duplicate
--- pending_review rows. The table already has UNIQUE (agent_id, client_name)
--- from 0002, but adding this partial UNIQUE index makes the dedupe intent
--- explicit and keeps the invariant intact even if the broader constraint
--- is ever loosened (e.g. to allow historical "ignored"/"adopted" copies).
--- SQLite supports partial indexes since 3.8.0 (2013).
-CREATE UNIQUE INDEX IF NOT EXISTS idx_discovered_clients_pending_unique
- ON discovered_clients (agent_id, client_name)
- WHERE status = 'pending_review';
-
--- +goose Down
-DROP INDEX IF EXISTS idx_discovered_clients_pending_unique;
diff --git a/db/migrations/sqlite/0011_column_drift.sql b/db/migrations/sqlite/0011_column_drift.sql
deleted file mode 100644
index f5f58dfd..00000000
--- a/db/migrations/sqlite/0011_column_drift.sql
+++ /dev/null
@@ -1,21 +0,0 @@
--- +goose Up
--- P2-DB-05 (DF-25 / M-F13): rename SQLite columns to match the PostgreSQL
--- schema. Historically SQLite used `details_json` and `values_json` (TEXT)
--- while Postgres used the unsuffixed `details` and `values` (JSONB) names.
--- That divergence forced every Store method to keep two SQL variants — one
--- per backend — and any edit had to touch both. Converging on the Postgres
--- names lets the storage layer share statements.
---
--- SQLite supports ALTER TABLE ... RENAME COLUMN since 3.25 (2018). The
--- modernc.org/sqlite driver we embed is based on 3.45+, so direct RENAME
--- COLUMN works — no table-rebuild fallback needed.
---
--- `values` is a reserved keyword in SQLite, so any subsequent query that
--- refers to it must double-quote the identifier. That change lives in
--- internal/controlplane/storage/sqlite/store.go alongside this migration.
-ALTER TABLE audit_events RENAME COLUMN details_json TO details;
-ALTER TABLE metric_snapshots RENAME COLUMN values_json TO "values";
-
--- +goose Down
-ALTER TABLE audit_events RENAME COLUMN details TO details_json;
-ALTER TABLE metric_snapshots RENAME COLUMN "values" TO values_json;
diff --git a/db/migrations/sqlite/0012_cascade_fk.sql b/db/migrations/sqlite/0012_cascade_fk.sql
deleted file mode 100644
index df6b008e..00000000
--- a/db/migrations/sqlite/0012_cascade_fk.sql
+++ /dev/null
@@ -1,147 +0,0 @@
--- +goose Up
--- +goose NO TRANSACTION
--- P2-DB-03 (DF-24 / M-F11): SQLite does not support ALTER TABLE ADD
--- CONSTRAINT, so we rebuild each affected table with the correct FK set and
--- copy rows across.
---
--- Dev-stage policy (plan v4): operate on dev databases only. Drop-and-
--- recreate is acceptable, orphan rows are purged before the rebuild so the
--- new FK constraints do not abort the copy. No down.sql.
---
--- Goose runs the migration on a single connection, so PRAGMA foreign_keys
--- toggling is scoped to this rebuild and does not leak to other connections.
-
-PRAGMA foreign_keys = OFF;
-
--- sessions: current columns per 0004_sessions.sql: (id, user_id, created_at_unix).
--- Each table rebuild below is wrapped in its own explicit transaction so a
--- crash between DROP and RENAME can never leave a table dropped-but-not-
--- renamed. PRAGMA foreign_keys stays outside every BEGIN/COMMIT — SQLite
--- forbids toggling it inside a transaction.
-DELETE FROM sessions WHERE user_id NOT IN (SELECT id FROM users);
-
-BEGIN;
-
-CREATE TABLE sessions_new (
- id TEXT PRIMARY KEY,
- user_id TEXT NOT NULL,
- created_at_unix INTEGER NOT NULL,
- FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
-);
-
-INSERT INTO sessions_new (id, user_id, created_at_unix)
-SELECT id, user_id, created_at_unix FROM sessions;
-
-DROP TABLE sessions;
-ALTER TABLE sessions_new RENAME TO sessions;
-
-CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions (user_id);
-CREATE INDEX IF NOT EXISTS idx_sessions_created_at_unix ON sessions (created_at_unix);
-
-COMMIT;
-
--- enrollment_tokens: deliberately NOT linked to fleet_groups by a FK — the
--- control-plane issues tokens against a fleet group id that may not yet
--- exist (agent_flow.consumeEnrollmentToken creates the group on first
--- enrollment). Adding a FK here would break the issue-then-create flow.
-
--- metric_snapshots: current columns per 0001_init.sql + 0011_column_drift.sql:
--- (id, agent_id, instance_id, captured_at_unix, "values"). The `values`
--- column was renamed from `values_json` in 0011, and since `values` is a
--- reserved keyword in SQLite it must be double-quoted.
-DELETE FROM metric_snapshots WHERE agent_id NOT IN (SELECT id FROM agents);
-
-BEGIN;
-
-CREATE TABLE metric_snapshots_new (
- id TEXT PRIMARY KEY,
- agent_id TEXT NOT NULL,
- instance_id TEXT NOT NULL DEFAULT '',
- captured_at_unix INTEGER NOT NULL,
- "values" TEXT NOT NULL,
- FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE
-);
-
-INSERT INTO metric_snapshots_new (id, agent_id, instance_id, captured_at_unix, "values")
-SELECT id, agent_id, instance_id, captured_at_unix, "values" FROM metric_snapshots;
-
-DROP TABLE metric_snapshots;
-ALTER TABLE metric_snapshots_new RENAME TO metric_snapshots;
-
-CREATE INDEX IF NOT EXISTS idx_metric_snapshots_captured_at ON metric_snapshots (captured_at_unix);
-CREATE INDEX IF NOT EXISTS idx_metric_snapshots_agent_captured ON metric_snapshots (agent_id, captured_at_unix);
-
-COMMIT;
-
--- client_assignments: current columns per 0001_init.sql:
--- (id, client_id, target_type, fleet_group_id, agent_id, created_at_unix).
-DELETE FROM client_assignments WHERE client_id NOT IN (SELECT id FROM clients);
-DELETE FROM client_assignments
-WHERE fleet_group_id IS NOT NULL
- AND fleet_group_id NOT IN (SELECT id FROM fleet_groups);
-DELETE FROM client_assignments
-WHERE agent_id IS NOT NULL
- AND agent_id NOT IN (SELECT id FROM agents);
-
-BEGIN;
-
-CREATE TABLE client_assignments_new (
- id TEXT PRIMARY KEY,
- client_id TEXT NOT NULL,
- target_type TEXT NOT NULL,
- fleet_group_id TEXT,
- agent_id TEXT,
- created_at_unix INTEGER NOT NULL,
- FOREIGN KEY (client_id) REFERENCES clients (id) ON DELETE CASCADE,
- FOREIGN KEY (fleet_group_id) REFERENCES fleet_groups (id) ON DELETE SET NULL,
- FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE SET NULL
-);
-
-INSERT INTO client_assignments_new (id, client_id, target_type, fleet_group_id, agent_id, created_at_unix)
-SELECT id, client_id, target_type, fleet_group_id, agent_id, created_at_unix FROM client_assignments;
-
-DROP TABLE client_assignments;
-ALTER TABLE client_assignments_new RENAME TO client_assignments;
-
-CREATE INDEX IF NOT EXISTS idx_client_assignments_client_id ON client_assignments (client_id);
-
-COMMIT;
-
--- client_deployments: current columns per 0001_init.sql:
--- (client_id, agent_id, desired_operation, status, last_error,
--- connection_link, last_applied_at_unix, updated_at_unix).
-DELETE FROM client_deployments WHERE client_id NOT IN (SELECT id FROM clients);
-DELETE FROM client_deployments WHERE agent_id NOT IN (SELECT id FROM agents);
-
-BEGIN;
-
-CREATE TABLE client_deployments_new (
- client_id TEXT NOT NULL,
- agent_id TEXT NOT NULL,
- desired_operation TEXT NOT NULL,
- status TEXT NOT NULL,
- last_error TEXT NOT NULL DEFAULT '',
- connection_link TEXT NOT NULL DEFAULT '',
- last_applied_at_unix INTEGER,
- updated_at_unix INTEGER NOT NULL,
- PRIMARY KEY (client_id, agent_id),
- FOREIGN KEY (client_id) REFERENCES clients (id) ON DELETE CASCADE,
- FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE
-);
-
-INSERT INTO client_deployments_new (client_id, agent_id, desired_operation, status, last_error, connection_link, last_applied_at_unix, updated_at_unix)
-SELECT client_id, agent_id, desired_operation, status, last_error, connection_link, last_applied_at_unix, updated_at_unix FROM client_deployments;
-
-DROP TABLE client_deployments;
-ALTER TABLE client_deployments_new RENAME TO client_deployments;
-
-CREATE INDEX IF NOT EXISTS idx_client_deployments_client_id ON client_deployments (client_id);
-
-COMMIT;
-
-PRAGMA foreign_keys = ON;
-
--- +goose Down
--- +goose NO TRANSACTION
--- dev-stage: drop+recreate acceptable, no rollback.
-SELECT 1;
diff --git a/db/migrations/sqlite/0013_login_lockout.sql b/db/migrations/sqlite/0013_login_lockout.sql
deleted file mode 100644
index f66c3f2d..00000000
--- a/db/migrations/sqlite/0013_login_lockout.sql
+++ /dev/null
@@ -1,19 +0,0 @@
--- +goose Up
--- S7: persist login-lockout state so a control-plane restart or
--- fail-over cannot be used to reset the failed-attempt counter for
--- an account. Key is the raw username. SQLite stores timestamps as
--- INTEGER unix seconds to match the rest of the schema (see sessions,
--- metric_snapshots, etc.).
-CREATE TABLE IF NOT EXISTS login_lockouts (
- username TEXT PRIMARY KEY,
- failures INTEGER NOT NULL DEFAULT 0,
- locked_at_unix INTEGER,
- updated_at_unix INTEGER NOT NULL
-);
-
-CREATE INDEX IF NOT EXISTS idx_login_lockouts_locked_at_unix
- ON login_lockouts(locked_at_unix);
-
--- +goose Down
-DROP INDEX IF EXISTS idx_login_lockouts_locked_at_unix;
-DROP TABLE IF EXISTS login_lockouts;
diff --git a/db/migrations/sqlite/0014_fleet_groups_redesign.sql b/db/migrations/sqlite/0014_fleet_groups_redesign.sql
deleted file mode 100644
index cce81d79..00000000
--- a/db/migrations/sqlite/0014_fleet_groups_redesign.sql
+++ /dev/null
@@ -1,65 +0,0 @@
--- +goose Up
--- Fleet groups redesign: see postgres/0014 for the full rationale.
--- SQLite supports ADD COLUMN but not CHANGE COLUMN, so we add the
--- new fields in place and backfill.
-ALTER TABLE fleet_groups ADD COLUMN label TEXT NOT NULL DEFAULT '';
-ALTER TABLE fleet_groups ADD COLUMN description TEXT NOT NULL DEFAULT '';
-ALTER TABLE fleet_groups ADD COLUMN updated_at_unix INTEGER NOT NULL DEFAULT 0;
-
--- Backfill label from the existing name and seed updated_at from
--- created_at so pre-migration rows have plausible values. The columns
--- are NOT NULL DEFAULT '' / 0 (added above), so every row landed with
--- the sentinel values we filter on; a follow-up migration that runs
--- after operators have set custom labels must skip these branches.
-UPDATE fleet_groups SET label = name WHERE length(label) = 0;
-UPDATE fleet_groups SET updated_at_unix = created_at_unix WHERE updated_at_unix = 0;
-
--- SQLite pre-3.35 cannot ADD CONSTRAINT, but a UNIQUE index does the
--- same job and is lighter than rebuilding the table.
-CREATE UNIQUE INDEX IF NOT EXISTS fleet_groups_name_unique
- ON fleet_groups (name);
-
-CREATE TABLE IF NOT EXISTS integration_providers (
- id TEXT PRIMARY KEY,
- kind TEXT NOT NULL,
- label TEXT NOT NULL DEFAULT '',
- config TEXT NOT NULL DEFAULT '{}',
- created_at_unix INTEGER NOT NULL,
- updated_at_unix INTEGER NOT NULL
-);
-
-CREATE INDEX IF NOT EXISTS idx_integration_providers_kind
- ON integration_providers (kind);
-
-CREATE TABLE IF NOT EXISTS fleet_group_integrations (
- id TEXT PRIMARY KEY,
- fleet_group_id TEXT NOT NULL,
- kind TEXT NOT NULL,
- provider_id TEXT,
- config TEXT NOT NULL DEFAULT '{}',
- enabled INTEGER NOT NULL DEFAULT 0,
- created_at_unix INTEGER NOT NULL,
- updated_at_unix INTEGER NOT NULL,
- FOREIGN KEY (fleet_group_id) REFERENCES fleet_groups (id) ON DELETE CASCADE,
- FOREIGN KEY (provider_id) REFERENCES integration_providers (id) ON DELETE SET NULL,
- UNIQUE (fleet_group_id, kind)
-);
-
-CREATE INDEX IF NOT EXISTS idx_fleet_group_integrations_fleet_group_id
- ON fleet_group_integrations (fleet_group_id);
-CREATE INDEX IF NOT EXISTS idx_fleet_group_integrations_kind
- ON fleet_group_integrations (kind);
-
--- +goose Down
-DROP INDEX IF EXISTS idx_fleet_group_integrations_kind;
-DROP INDEX IF EXISTS idx_fleet_group_integrations_fleet_group_id;
-DROP TABLE IF EXISTS fleet_group_integrations;
-DROP INDEX IF EXISTS idx_integration_providers_kind;
-DROP TABLE IF EXISTS integration_providers;
-
--- SQLite supports DROP COLUMN from 3.35 (modernc.org/sqlite pins a
--- recent enough version). Index drop is idempotent.
-DROP INDEX IF EXISTS fleet_groups_name_unique;
-ALTER TABLE fleet_groups DROP COLUMN updated_at_unix;
-ALTER TABLE fleet_groups DROP COLUMN description;
-ALTER TABLE fleet_groups DROP COLUMN label;
diff --git a/db/migrations/sqlite/0015_client_usage.sql b/db/migrations/sqlite/0015_client_usage.sql
deleted file mode 100644
index 62f1581c..00000000
--- a/db/migrations/sqlite/0015_client_usage.sql
+++ /dev/null
@@ -1,24 +0,0 @@
--- +goose Up
--- SQLite mirror of 0015 (Postgres). Schema-sync parity is enforced by
--- TestSchemaSyncPostgresMatchesSQLite. Timestamps use TEXT (ISO-8601)
--- like the rest of the SQLite bundle; BIGINT collapses to INTEGER.
-CREATE TABLE IF NOT EXISTS client_usage (
- client_id TEXT NOT NULL,
- agent_id TEXT NOT NULL,
- traffic_used_bytes INTEGER NOT NULL DEFAULT 0,
- unique_ips_used INTEGER NOT NULL DEFAULT 0,
- active_tcp_conns INTEGER NOT NULL DEFAULT 0,
- active_unique_ips INTEGER NOT NULL DEFAULT 0,
- last_seq INTEGER NOT NULL DEFAULT 0,
- observed_at_unix INTEGER NOT NULL,
- PRIMARY KEY (client_id, agent_id),
- FOREIGN KEY (client_id) REFERENCES clients (id) ON DELETE CASCADE,
- FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE
-);
-
-CREATE INDEX IF NOT EXISTS idx_client_usage_agent_id
- ON client_usage (agent_id);
-
--- +goose Down
-DROP INDEX IF EXISTS idx_client_usage_agent_id;
-DROP TABLE IF EXISTS client_usage;
diff --git a/db/migrations/sqlite/0016_sessions_last_seen.sql b/db/migrations/sqlite/0016_sessions_last_seen.sql
deleted file mode 100644
index 48acffa9..00000000
--- a/db/migrations/sqlite/0016_sessions_last_seen.sql
+++ /dev/null
@@ -1,18 +0,0 @@
--- +goose Up
-ALTER TABLE sessions ADD COLUMN last_seen_at_unix INTEGER NOT NULL DEFAULT 0;
-UPDATE sessions SET last_seen_at_unix = created_at_unix WHERE last_seen_at_unix = 0;
-
--- +goose Down
--- SQLite cannot DROP COLUMN inline (older versions); rebuild table.
-CREATE TABLE sessions_old (
- id TEXT PRIMARY KEY,
- user_id TEXT NOT NULL,
- created_at_unix INTEGER NOT NULL,
- FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
-);
-INSERT INTO sessions_old (id, user_id, created_at_unix)
-SELECT id, user_id, created_at_unix FROM sessions;
-DROP TABLE sessions;
-ALTER TABLE sessions_old RENAME TO sessions;
-CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
-CREATE INDEX IF NOT EXISTS idx_sessions_created_at_unix ON sessions(created_at_unix);
diff --git a/db/migrations/sqlite/0017_consumed_totp.sql b/db/migrations/sqlite/0017_consumed_totp.sql
deleted file mode 100644
index c0a28af1..00000000
--- a/db/migrations/sqlite/0017_consumed_totp.sql
+++ /dev/null
@@ -1,12 +0,0 @@
--- +goose Up
-CREATE TABLE IF NOT EXISTS consumed_totp (
- user_id TEXT NOT NULL,
- code TEXT NOT NULL,
- used_at_unix INTEGER NOT NULL,
- PRIMARY KEY (user_id, code)
-);
-CREATE INDEX IF NOT EXISTS idx_consumed_totp_used_at ON consumed_totp(used_at_unix);
-
--- +goose Down
-DROP INDEX IF EXISTS idx_consumed_totp_used_at;
-DROP TABLE IF EXISTS consumed_totp;
diff --git a/db/migrations/sqlite/0018_cp_secrets.sql b/db/migrations/sqlite/0018_cp_secrets.sql
deleted file mode 100644
index d85a988a..00000000
--- a/db/migrations/sqlite/0018_cp_secrets.sql
+++ /dev/null
@@ -1,9 +0,0 @@
--- +goose Up
-CREATE TABLE IF NOT EXISTS cp_secrets (
- key TEXT PRIMARY KEY,
- value BLOB NOT NULL,
- updated_at_unix INTEGER NOT NULL
-);
-
--- +goose Down
-DROP TABLE IF EXISTS cp_secrets;
diff --git a/db/migrations/sqlite/0019_jobs_actor_id_index.sql b/db/migrations/sqlite/0019_jobs_actor_id_index.sql
deleted file mode 100644
index bb9f788e..00000000
--- a/db/migrations/sqlite/0019_jobs_actor_id_index.sql
+++ /dev/null
@@ -1,5 +0,0 @@
--- +goose Up
-CREATE INDEX IF NOT EXISTS idx_jobs_actor_id ON jobs(actor_id);
-
--- +goose Down
-DROP INDEX IF EXISTS idx_jobs_actor_id;
diff --git a/db/migrations/sqlite/0020_agents_cert_serial.sql b/db/migrations/sqlite/0020_agents_cert_serial.sql
deleted file mode 100644
index 66f78046..00000000
--- a/db/migrations/sqlite/0020_agents_cert_serial.sql
+++ /dev/null
@@ -1,10 +0,0 @@
--- +goose Up
-ALTER TABLE agents ADD COLUMN cert_serial TEXT NOT NULL DEFAULT '';
-
--- +goose Down
--- SQLite cannot DROP COLUMN inline on older versions; rebuild table.
-CREATE TABLE agents_old AS SELECT id, node_name, fleet_group_id, version, read_only,
- last_seen_at_unix, cert_issued_at_unix, cert_expires_at_unix
- FROM agents;
-DROP TABLE agents;
-ALTER TABLE agents_old RENAME TO agents;
diff --git a/db/migrations/sqlite/0021_enrollment_token_hash.sql b/db/migrations/sqlite/0021_enrollment_token_hash.sql
deleted file mode 100644
index 8c32caeb..00000000
--- a/db/migrations/sqlite/0021_enrollment_token_hash.sql
+++ /dev/null
@@ -1,20 +0,0 @@
--- +goose Up
-ALTER TABLE enrollment_tokens ADD COLUMN value_hash TEXT NOT NULL DEFAULT '';
-CREATE INDEX IF NOT EXISTS idx_enrollment_tokens_value_hash ON enrollment_tokens(value_hash);
-
--- +goose Down
-DROP INDEX IF EXISTS idx_enrollment_tokens_value_hash;
--- SQLite < 3.35 cannot DROP COLUMN inline; rebuild a copy without value_hash.
-CREATE TABLE enrollment_tokens_old (
- value TEXT PRIMARY KEY,
- fleet_group_id TEXT,
- issued_at_unix INTEGER NOT NULL,
- expires_at_unix INTEGER NOT NULL,
- consumed_at_unix INTEGER,
- revoked_at_unix INTEGER
-);
-INSERT INTO enrollment_tokens_old (value, fleet_group_id, issued_at_unix, expires_at_unix, consumed_at_unix, revoked_at_unix)
- SELECT value, fleet_group_id, issued_at_unix, expires_at_unix, consumed_at_unix, revoked_at_unix
- FROM enrollment_tokens;
-DROP TABLE enrollment_tokens;
-ALTER TABLE enrollment_tokens_old RENAME TO enrollment_tokens;
diff --git a/db/migrations/sqlite/0022_cascade_fk_telemt_instances.sql b/db/migrations/sqlite/0022_cascade_fk_telemt_instances.sql
deleted file mode 100644
index 73f85283..00000000
--- a/db/migrations/sqlite/0022_cascade_fk_telemt_instances.sql
+++ /dev/null
@@ -1,9 +0,0 @@
--- +goose Up
--- Q4.U-D-03: no-op for sqlite — telemt_instances already carries
--- ON DELETE CASCADE via 0012_cascade_fk.sql. Migration files are kept
--- in lockstep across drivers so the postgres-side change shares the
--- same number.
-SELECT 1;
-
--- +goose Down
-SELECT 1;
diff --git a/db/migrations/sqlite/0023_check_constraints.sql b/db/migrations/sqlite/0023_check_constraints.sql
deleted file mode 100644
index 6c46d053..00000000
--- a/db/migrations/sqlite/0023_check_constraints.sql
+++ /dev/null
@@ -1,11 +0,0 @@
--- +goose Up
--- Q4.U-D-02: SQLite cannot ADD CONSTRAINT on an existing table; the
--- equivalent guard is a CHECK in CREATE TABLE. Adding it inline would
--- require rebuilding three tables under SQLite, which is invasive
--- enough that we defer to schema-rebuild work in the typed-config
--- sweep. Postgres carries the canonical guard; SQLite relies on the
--- application layer (jobs.IsValidStatus etc.) until then.
-SELECT 1;
-
--- +goose Down
-SELECT 1;
diff --git a/db/migrations/sqlite/0024_unify_real_to_double.sql b/db/migrations/sqlite/0024_unify_real_to_double.sql
deleted file mode 100644
index 04b01513..00000000
--- a/db/migrations/sqlite/0024_unify_real_to_double.sql
+++ /dev/null
@@ -1,8 +0,0 @@
--- +goose Up
--- Q5.U-D-01: no-op for sqlite — REAL is already 8-byte IEEE-754 there.
--- The companion postgres migration promotes its REAL columns to
--- DOUBLE PRECISION. Migration numbers stay in lockstep across drivers.
-SELECT 1;
-
--- +goose Down
-SELECT 1;
diff --git a/db/migrations/sqlite/0025_user_fleet_group_scopes.sql b/db/migrations/sqlite/0025_user_fleet_group_scopes.sql
deleted file mode 100644
index d1d86bf0..00000000
--- a/db/migrations/sqlite/0025_user_fleet_group_scopes.sql
+++ /dev/null
@@ -1,19 +0,0 @@
--- +goose Up
--- R-S-14: SQLite mirror of postgres migration 0025. fleet_groups.id is
--- TEXT (not UUID) on the SQLite side — schema 0014 keeps that legacy
--- shape. ON DELETE CASCADE matches the postgres semantics.
-CREATE TABLE IF NOT EXISTS user_fleet_group_scopes (
- user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
- fleet_group_id TEXT NOT NULL REFERENCES fleet_groups (id) ON DELETE CASCADE,
- granted_at_unix INTEGER NOT NULL DEFAULT 0,
- granted_by TEXT NOT NULL DEFAULT '',
- PRIMARY KEY (user_id, fleet_group_id)
-);
-
-CREATE INDEX IF NOT EXISTS idx_user_fleet_group_scopes_user_id
- ON user_fleet_group_scopes (user_id);
-CREATE INDEX IF NOT EXISTS idx_user_fleet_group_scopes_fleet_group_id
- ON user_fleet_group_scopes (fleet_group_id);
-
--- +goose Down
-DROP TABLE IF EXISTS user_fleet_group_scopes;
diff --git a/db/migrations/sqlite/0026_enum_check_constraints.sql b/db/migrations/sqlite/0026_enum_check_constraints.sql
deleted file mode 100644
index 47c58f5c..00000000
--- a/db/migrations/sqlite/0026_enum_check_constraints.sql
+++ /dev/null
@@ -1,128 +0,0 @@
--- +goose Up
--- +goose NO TRANSACTION
--- R-D-02-sqlite: bring SQLite into parity with postgres migration 0023
--- by rebuilding jobs / job_targets / discovered_clients with explicit
--- CHECK constraints on their enum-shaped TEXT columns. SQLite does not
--- support ALTER TABLE ADD CONSTRAINT, so the rebuild pattern from
--- 0012_cascade_fk is reused: rename → recreate → copy → drop → restore.
---
--- Dev-stage policy: drop-and-recreate is acceptable; rows that fail the
--- new CHECK are purged before the copy so the new constraint does not
--- abort the migration on dirty data.
-
-PRAGMA foreign_keys = OFF;
-
--- ─── jobs ────────────────────────────────────────────────────────────
--- Status enum mirrors jobs.IsValidStatus in the Go layer.
--- Each table rebuild below is wrapped in its own explicit transaction so a
--- crash between DROP and RENAME can never leave a table dropped-but-not-
--- renamed. PRAGMA foreign_keys stays outside every BEGIN/COMMIT — SQLite
--- forbids toggling it inside a transaction.
-DELETE FROM jobs WHERE status NOT IN ('queued','running','succeeded','failed','expired');
-
-BEGIN;
-
-CREATE TABLE jobs_new (
- id TEXT PRIMARY KEY,
- action TEXT NOT NULL,
- actor_id TEXT NOT NULL,
- status TEXT NOT NULL CHECK (status IN ('queued','running','succeeded','failed','expired')),
- created_at_unix INTEGER NOT NULL,
- ttl_nanos INTEGER NOT NULL,
- idempotency_key TEXT NOT NULL UNIQUE,
- payload_json TEXT NOT NULL DEFAULT ''
-);
-
-INSERT INTO jobs_new (id, action, actor_id, status, created_at_unix, ttl_nanos, idempotency_key, payload_json)
-SELECT id, action, actor_id, status, created_at_unix, ttl_nanos, idempotency_key, payload_json FROM jobs;
-
-DROP TABLE jobs;
-ALTER TABLE jobs_new RENAME TO jobs;
-
-CREATE INDEX IF NOT EXISTS idx_jobs_created_at ON jobs (created_at_unix);
-CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs (status);
-CREATE INDEX IF NOT EXISTS idx_jobs_actor_id ON jobs (actor_id);
-
-COMMIT;
-
--- ─── job_targets ─────────────────────────────────────────────────────
--- The application enum (jobs.TargetStatus) is queued / sent /
--- acknowledged / succeeded / failed / expired. The earlier postgres
--- migration 0023 typo'd `sent` as `dispatched` — postgres writes to
--- target.status="sent" trip the constraint. The follow-up migration
--- 0027_fix_job_targets_check_enum corrects postgres; the SQLite
--- constraint below is born correct.
-DELETE FROM job_targets WHERE status NOT IN ('queued','sent','acknowledged','succeeded','failed','expired');
-
-BEGIN;
-
-CREATE TABLE job_targets_new (
- job_id TEXT NOT NULL,
- agent_id TEXT NOT NULL,
- status TEXT NOT NULL CHECK (status IN ('queued','sent','acknowledged','succeeded','failed','expired')),
- result_text TEXT NOT NULL DEFAULT '',
- result_json TEXT NOT NULL DEFAULT '',
- updated_at_unix INTEGER NOT NULL,
- PRIMARY KEY (job_id, agent_id),
- FOREIGN KEY (job_id) REFERENCES jobs (id)
-);
-
-INSERT INTO job_targets_new (job_id, agent_id, status, result_text, result_json, updated_at_unix)
-SELECT job_id, agent_id, status, result_text, result_json, updated_at_unix FROM job_targets;
-
-DROP TABLE job_targets;
-ALTER TABLE job_targets_new RENAME TO job_targets;
-
-CREATE INDEX IF NOT EXISTS idx_job_targets_agent_id ON job_targets (agent_id);
-
-COMMIT;
-
--- ─── discovered_clients ──────────────────────────────────────────────
-DELETE FROM discovered_clients WHERE status NOT IN ('pending_review','adopted','ignored');
-
-BEGIN;
-
-CREATE TABLE discovered_clients_new (
- id TEXT PRIMARY KEY,
- agent_id TEXT NOT NULL,
- client_name TEXT NOT NULL,
- secret TEXT NOT NULL DEFAULT '',
- status TEXT NOT NULL DEFAULT 'pending_review' CHECK (status IN ('pending_review','adopted','ignored')),
- total_octets INTEGER NOT NULL DEFAULT 0,
- current_connections INTEGER NOT NULL DEFAULT 0,
- active_unique_ips INTEGER NOT NULL DEFAULT 0,
- connection_link TEXT NOT NULL DEFAULT '',
- max_tcp_conns INTEGER NOT NULL DEFAULT 0,
- max_unique_ips INTEGER NOT NULL DEFAULT 0,
- data_quota_bytes INTEGER NOT NULL DEFAULT 0,
- expiration TEXT NOT NULL DEFAULT '',
- discovered_at_unix INTEGER NOT NULL,
- updated_at_unix INTEGER NOT NULL,
- UNIQUE (agent_id, client_name),
- FOREIGN KEY (agent_id) REFERENCES agents (id)
-);
-
-INSERT INTO discovered_clients_new (
- id, agent_id, client_name, secret, status, total_octets, current_connections,
- active_unique_ips, connection_link, max_tcp_conns, max_unique_ips,
- data_quota_bytes, expiration, discovered_at_unix, updated_at_unix
-)
-SELECT id, agent_id, client_name, secret, status, total_octets, current_connections,
- active_unique_ips, connection_link, max_tcp_conns, max_unique_ips,
- data_quota_bytes, expiration, discovered_at_unix, updated_at_unix
-FROM discovered_clients;
-
-DROP TABLE discovered_clients;
-ALTER TABLE discovered_clients_new RENAME TO discovered_clients;
-
-CREATE INDEX IF NOT EXISTS idx_discovered_clients_agent_id ON discovered_clients (agent_id);
-CREATE UNIQUE INDEX IF NOT EXISTS idx_discovered_clients_pending_unique
- ON discovered_clients (agent_id, client_name)
- WHERE status = 'pending_review';
-
-COMMIT;
-
-PRAGMA foreign_keys = ON;
-
--- +goose Down
-SELECT 1;
diff --git a/db/migrations/sqlite/0027_fix_job_targets_check_enum.sql b/db/migrations/sqlite/0027_fix_job_targets_check_enum.sql
deleted file mode 100644
index 3d490d07..00000000
--- a/db/migrations/sqlite/0027_fix_job_targets_check_enum.sql
+++ /dev/null
@@ -1,9 +0,0 @@
--- +goose Up
--- SQLite's matching migration is a no-op: 0026 in this repo already
--- ships the corrected enum (queued / sent / acknowledged / succeeded
--- / failed / expired). This file exists so the version numbers stay
--- aligned across the postgres + sqlite trees.
-SELECT 1;
-
--- +goose Down
-SELECT 1;
diff --git a/db/migrations/sqlite/0028_cascade_fk_grants_discovered_instances.sql b/db/migrations/sqlite/0028_cascade_fk_grants_discovered_instances.sql
deleted file mode 100644
index 1f4b172b..00000000
--- a/db/migrations/sqlite/0028_cascade_fk_grants_discovered_instances.sql
+++ /dev/null
@@ -1,124 +0,0 @@
--- +goose Up
--- +goose NO TRANSACTION
--- Add ON DELETE CASCADE to the remaining FKs that point at agents (id):
--- * telemt_instances — 0022 was a no-op based on the wrong
--- assumption that 0012_cascade_fk had
--- rebuilt this table; it had not.
--- * agent_certificate_recovery_grants — never patched.
--- * discovered_clients — rebuilt in 0026 without cascade.
---
--- Without these, DELETE FROM agents fails with a FOREIGN KEY constraint
--- error whenever any of these tables hold rows for the deregistered agent
--- (see http_agents.persistAgentDeregister).
---
--- Dev-stage policy (matches 0012/0026): orphan rows are purged before
--- the rebuild so the new constraint does not abort the copy.
-
-PRAGMA foreign_keys = OFF;
-
--- ─── telemt_instances ────────────────────────────────────────────────
--- Each table rebuild below is wrapped in its own explicit transaction so a
--- crash between DROP and RENAME can never leave a table dropped-but-not-
--- renamed. PRAGMA foreign_keys stays outside every BEGIN/COMMIT — SQLite
--- forbids toggling it inside a transaction.
-DELETE FROM telemt_instances WHERE agent_id NOT IN (SELECT id FROM agents);
-
-BEGIN;
-
-CREATE TABLE telemt_instances_new (
- id TEXT PRIMARY KEY,
- agent_id TEXT NOT NULL,
- name TEXT NOT NULL,
- version TEXT NOT NULL DEFAULT '',
- config_fingerprint TEXT NOT NULL DEFAULT '',
- connected_users INTEGER NOT NULL DEFAULT 0,
- read_only INTEGER NOT NULL DEFAULT 0,
- updated_at_unix INTEGER NOT NULL,
- FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE
-);
-
-INSERT INTO telemt_instances_new (id, agent_id, name, version, config_fingerprint, connected_users, read_only, updated_at_unix)
-SELECT id, agent_id, name, version, config_fingerprint, connected_users, read_only, updated_at_unix FROM telemt_instances;
-
-DROP TABLE telemt_instances;
-ALTER TABLE telemt_instances_new RENAME TO telemt_instances;
-
-CREATE INDEX IF NOT EXISTS idx_telemt_instances_agent_id ON telemt_instances (agent_id);
-
-COMMIT;
-
--- ─── agent_certificate_recovery_grants ───────────────────────────────
-DELETE FROM agent_certificate_recovery_grants WHERE agent_id NOT IN (SELECT id FROM agents);
-
-BEGIN;
-
-CREATE TABLE agent_certificate_recovery_grants_new (
- agent_id TEXT PRIMARY KEY,
- issued_by TEXT NOT NULL,
- issued_at_unix INTEGER NOT NULL,
- expires_at_unix INTEGER NOT NULL,
- used_at_unix INTEGER,
- revoked_at_unix INTEGER,
- FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE
-);
-
-INSERT INTO agent_certificate_recovery_grants_new (agent_id, issued_by, issued_at_unix, expires_at_unix, used_at_unix, revoked_at_unix)
-SELECT agent_id, issued_by, issued_at_unix, expires_at_unix, used_at_unix, revoked_at_unix FROM agent_certificate_recovery_grants;
-
-DROP TABLE agent_certificate_recovery_grants;
-ALTER TABLE agent_certificate_recovery_grants_new RENAME TO agent_certificate_recovery_grants;
-
-COMMIT;
-
--- ─── discovered_clients ──────────────────────────────────────────────
--- Schema mirrors the post-0026 definition exactly; only the FK gains
--- ON DELETE CASCADE.
-DELETE FROM discovered_clients WHERE agent_id NOT IN (SELECT id FROM agents);
-
-BEGIN;
-
-CREATE TABLE discovered_clients_new (
- id TEXT PRIMARY KEY,
- agent_id TEXT NOT NULL,
- client_name TEXT NOT NULL,
- secret TEXT NOT NULL DEFAULT '',
- status TEXT NOT NULL DEFAULT 'pending_review' CHECK (status IN ('pending_review','adopted','ignored')),
- total_octets INTEGER NOT NULL DEFAULT 0,
- current_connections INTEGER NOT NULL DEFAULT 0,
- active_unique_ips INTEGER NOT NULL DEFAULT 0,
- connection_link TEXT NOT NULL DEFAULT '',
- max_tcp_conns INTEGER NOT NULL DEFAULT 0,
- max_unique_ips INTEGER NOT NULL DEFAULT 0,
- data_quota_bytes INTEGER NOT NULL DEFAULT 0,
- expiration TEXT NOT NULL DEFAULT '',
- discovered_at_unix INTEGER NOT NULL,
- updated_at_unix INTEGER NOT NULL,
- UNIQUE (agent_id, client_name),
- FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE
-);
-
-INSERT INTO discovered_clients_new (
- id, agent_id, client_name, secret, status, total_octets, current_connections,
- active_unique_ips, connection_link, max_tcp_conns, max_unique_ips,
- data_quota_bytes, expiration, discovered_at_unix, updated_at_unix
-)
-SELECT id, agent_id, client_name, secret, status, total_octets, current_connections,
- active_unique_ips, connection_link, max_tcp_conns, max_unique_ips,
- data_quota_bytes, expiration, discovered_at_unix, updated_at_unix
-FROM discovered_clients;
-
-DROP TABLE discovered_clients;
-ALTER TABLE discovered_clients_new RENAME TO discovered_clients;
-
-CREATE INDEX IF NOT EXISTS idx_discovered_clients_agent_id ON discovered_clients (agent_id);
-CREATE UNIQUE INDEX IF NOT EXISTS idx_discovered_clients_pending_unique
- ON discovered_clients (agent_id, client_name)
- WHERE status = 'pending_review';
-
-COMMIT;
-
-PRAGMA foreign_keys = ON;
-
--- +goose Down
--- Dev-stage: drop+recreate acceptable, no rollback.
-SELECT 1;
diff --git a/db/migrations/sqlite/0029_connection_links_array.sql b/db/migrations/sqlite/0029_connection_links_array.sql
deleted file mode 100644
index 6287eea3..00000000
--- a/db/migrations/sqlite/0029_connection_links_array.sql
+++ /dev/null
@@ -1,37 +0,0 @@
--- +goose Up
--- +goose NO TRANSACTION
--- Replace the single-link `connection_link` column on client_deployments
--- and discovered_clients with `connection_links` carrying a JSON array.
--- Telemt's tls_domains config emits one TLS link per domain (×host); the
--- agent used to collapse that to a single string and the panel never saw
--- the alternates the operator configured.
---
--- Dev-stage: drop the legacy column outright. Existing rows with a single
--- non-empty link are migrated into the new JSON array via a one-shot
--- UPDATE before the column drop so live data isn't lost.
-
-PRAGMA foreign_keys = OFF;
-
--- ─── client_deployments ──────────────────────────────────────────────
-ALTER TABLE client_deployments ADD COLUMN connection_links TEXT NOT NULL DEFAULT '[]';
-
-UPDATE client_deployments
-SET connection_links = json_array(connection_link)
-WHERE connection_link != '';
-
-ALTER TABLE client_deployments DROP COLUMN connection_link;
-
--- ─── discovered_clients ──────────────────────────────────────────────
-ALTER TABLE discovered_clients ADD COLUMN connection_links TEXT NOT NULL DEFAULT '[]';
-
-UPDATE discovered_clients
-SET connection_links = json_array(connection_link)
-WHERE connection_link != '';
-
-ALTER TABLE discovered_clients DROP COLUMN connection_link;
-
-PRAGMA foreign_keys = ON;
-
--- +goose Down
--- Dev-stage: drop+recreate acceptable, no rollback.
-SELECT 1;
diff --git a/db/migrations/sqlite/0030_node_transport_mode.sql b/db/migrations/sqlite/0030_node_transport_mode.sql
deleted file mode 100644
index e482c616..00000000
--- a/db/migrations/sqlite/0030_node_transport_mode.sql
+++ /dev/null
@@ -1,27 +0,0 @@
--- +goose Up
--- Add transport-mode fields to agents so the panel can distinguish inbound
--- agents (the default: agent dials the panel) from outbound/reverse-tunnel
--- agents (panel dials the agent via a stored dial_address).
--- bootstrap_* columns support a one-time token exchange for outbound agents.
-
-ALTER TABLE agents ADD COLUMN transport_mode TEXT NOT NULL DEFAULT 'inbound'
- CHECK (transport_mode IN ('inbound', 'outbound'));
-ALTER TABLE agents ADD COLUMN dial_address TEXT;
-ALTER TABLE agents ADD COLUMN bootstrap_state TEXT NOT NULL DEFAULT 'active'
- CHECK (bootstrap_state IN ('pending', 'active', 'expired', 'revoked'));
-ALTER TABLE agents ADD COLUMN bootstrap_token_hash BLOB;
-ALTER TABLE agents ADD COLUMN bootstrap_expires_at INTEGER;
-
-CREATE INDEX IF NOT EXISTS idx_agents_transport_mode ON agents(transport_mode);
-
--- Existing agents default to bootstrap_state=active (already enrolled).
--- New outbound agents are created with bootstrap_state=pending
--- (see bootstrap-flow in subsequent tasks).
-
--- +goose Down
-DROP INDEX IF EXISTS idx_agents_transport_mode;
-ALTER TABLE agents DROP COLUMN bootstrap_expires_at;
-ALTER TABLE agents DROP COLUMN bootstrap_token_hash;
-ALTER TABLE agents DROP COLUMN bootstrap_state;
-ALTER TABLE agents DROP COLUMN dial_address;
-ALTER TABLE agents DROP COLUMN transport_mode;
diff --git a/db/migrations/sqlite/0031_agent_fallback_state.sql b/db/migrations/sqlite/0031_agent_fallback_state.sql
deleted file mode 100644
index 48bab8fa..00000000
--- a/db/migrations/sqlite/0031_agent_fallback_state.sql
+++ /dev/null
@@ -1,13 +0,0 @@
--- +goose Up
-CREATE TABLE IF NOT EXISTS agent_fallback_state (
- agent_id TEXT PRIMARY KEY,
- entered_at_unix INTEGER NOT NULL,
- FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE
-);
-
-CREATE INDEX IF NOT EXISTS idx_agent_fallback_state_entered_at
- ON agent_fallback_state (entered_at_unix);
-
--- +goose Down
-DROP INDEX IF EXISTS idx_agent_fallback_state_entered_at;
-DROP TABLE IF EXISTS agent_fallback_state;
diff --git a/db/migrations/sqlite/0032_password_policy.sql b/db/migrations/sqlite/0032_password_policy.sql
deleted file mode 100644
index 748e4ef1..00000000
--- a/db/migrations/sqlite/0032_password_policy.sql
+++ /dev/null
@@ -1,10 +0,0 @@
--- +goose Up
--- S-01: configurable minimum password length on panel_settings.
--- SQLite ALTER TABLE supports ADD COLUMN with NOT NULL+DEFAULT since 3.35.
--- CHECK constraint must be inline (cannot ADD CHECK on existing column).
-ALTER TABLE panel_settings ADD COLUMN password_min_length INTEGER NOT NULL DEFAULT 10;
-
--- +goose Down
--- SQLite < 3.35 cannot DROP COLUMN; modern builds (>= 3.35) used by
--- modernc.org/sqlite v1.50 support it.
-ALTER TABLE panel_settings DROP COLUMN password_min_length;
diff --git a/db/migrations/sqlite/0033_agent_cert_pin.sql b/db/migrations/sqlite/0033_agent_cert_pin.sql
deleted file mode 100644
index 22fd5e3f..00000000
--- a/db/migrations/sqlite/0033_agent_cert_pin.sql
+++ /dev/null
@@ -1,15 +0,0 @@
--- +goose Up
--- S-02: SPKI pin column. SQLite stores BLOB; partial indexes are supported
--- since 3.8.0 (modernc/sqlite v1.50 ships SQLite 3.46+).
--- CHECK constraint must be inline (cannot ADD CHECK on existing column);
--- mirrors the same restriction documented in 0032_password_policy.sql.
-ALTER TABLE agents ADD COLUMN cert_spki_sha256 BLOB NOT NULL DEFAULT x'';
-
-CREATE INDEX idx_agents_cert_spki_sha256
- ON agents (cert_spki_sha256)
- WHERE length(cert_spki_sha256) > 0;
-
--- +goose Down
--- SQLite >= 3.35 supports DROP COLUMN (modernc v1.50 ships 3.46+).
-DROP INDEX IF EXISTS idx_agents_cert_spki_sha256;
-ALTER TABLE agents DROP COLUMN IF EXISTS cert_spki_sha256;
diff --git a/db/migrations/sqlite/0034_geoip_settings.sql b/db/migrations/sqlite/0034_geoip_settings.sql
deleted file mode 100644
index 37c99029..00000000
--- a/db/migrations/sqlite/0034_geoip_settings.sql
+++ /dev/null
@@ -1,7 +0,0 @@
--- +goose Up
-ALTER TABLE panel_settings ADD COLUMN geoip_json TEXT NOT NULL DEFAULT '';
-ALTER TABLE panel_settings ADD COLUMN geoip_state_json TEXT NOT NULL DEFAULT '';
-
--- +goose Down
-ALTER TABLE panel_settings DROP COLUMN geoip_state_json;
-ALTER TABLE panel_settings DROP COLUMN geoip_json;
diff --git a/db/migrations/sqlite/0035_telemt_unreachable.sql b/db/migrations/sqlite/0035_telemt_unreachable.sql
deleted file mode 100644
index 08043510..00000000
--- a/db/migrations/sqlite/0035_telemt_unreachable.sql
+++ /dev/null
@@ -1,9 +0,0 @@
--- +goose Up
-ALTER TABLE telemt_runtime_current
- ADD COLUMN telemt_reachable INTEGER NOT NULL DEFAULT 1;
-ALTER TABLE telemt_runtime_current
- ADD COLUMN telemt_unreachable_since_unix INTEGER NOT NULL DEFAULT 0;
-
--- +goose Down
-ALTER TABLE telemt_runtime_current DROP COLUMN telemt_unreachable_since_unix;
-ALTER TABLE telemt_runtime_current DROP COLUMN telemt_reachable;
diff --git a/db/migrations/sqlite/0036_drop_bootstrap_columns_from_panel_settings.sql b/db/migrations/sqlite/0036_drop_bootstrap_columns_from_panel_settings.sql
deleted file mode 100644
index 28630103..00000000
--- a/db/migrations/sqlite/0036_drop_bootstrap_columns_from_panel_settings.sql
+++ /dev/null
@@ -1,30 +0,0 @@
--- +goose Up
--- 0036: drop bootstrap-only columns from panel_settings.
--- Project is pre-release; no compatibility shim.
--- These columns are now sourced from env/config.toml via the settings registry.
-
-CREATE TABLE panel_settings_backup (
- scope TEXT PRIMARY KEY,
- http_public_url TEXT NOT NULL DEFAULT '',
- grpc_public_endpoint TEXT NOT NULL DEFAULT '',
- password_min_length INTEGER NOT NULL DEFAULT 10,
- retention_json TEXT NOT NULL DEFAULT '',
- geoip_json TEXT NOT NULL DEFAULT '',
- geoip_state_json TEXT NOT NULL DEFAULT '',
- updated_at_unix INTEGER NOT NULL
-);
-
-INSERT INTO panel_settings_backup
- (scope, http_public_url, grpc_public_endpoint,
- password_min_length, retention_json, geoip_json,
- geoip_state_json, updated_at_unix)
-SELECT scope, http_public_url, grpc_public_endpoint,
- password_min_length, retention_json, geoip_json,
- geoip_state_json, updated_at_unix
- FROM panel_settings;
-
-DROP TABLE panel_settings;
-ALTER TABLE panel_settings_backup RENAME TO panel_settings;
-
--- +goose Down
--- intentionally empty (pre-release, no compatibility shim)
diff --git a/db/migrations/sqlite/0037_runtime_settings.sql b/db/migrations/sqlite/0037_runtime_settings.sql
deleted file mode 100644
index f129e287..00000000
--- a/db/migrations/sqlite/0037_runtime_settings.sql
+++ /dev/null
@@ -1,12 +0,0 @@
--- +goose Up
--- 0037: kv-table for operational settings without dedicated columns.
--- Used for fields produced by sub-project B (poll intervals, etc.).
-CREATE TABLE runtime_settings (
- name TEXT PRIMARY KEY,
- value_json TEXT NOT NULL,
- updated_at INTEGER NOT NULL,
- updated_by TEXT NOT NULL DEFAULT ''
-);
-
--- +goose Down
-DROP TABLE runtime_settings;
diff --git a/db/migrations/sqlite/0038_audit_hash_chain.sql b/db/migrations/sqlite/0038_audit_hash_chain.sql
deleted file mode 100644
index a456fe9b..00000000
--- a/db/migrations/sqlite/0038_audit_hash_chain.sql
+++ /dev/null
@@ -1,22 +0,0 @@
--- +goose Up
--- 0038: tamper-evident audit chain.
--- Mirror of the postgres 0038 migration. Pre-release: existing rows
--- carry the empty default; the chain begins forming on the first
--- post-migration write.
---
--- SQLite cannot add multiple columns in one ALTER TABLE statement,
--- so we issue two separate ALTERs. This is a no-op rewrite under
--- modernc.org/sqlite (column adds are O(1) for non-PRIMARY KEY columns).
-
-ALTER TABLE audit_events ADD COLUMN prev_hash TEXT NOT NULL DEFAULT '';
-ALTER TABLE audit_events ADD COLUMN event_hash TEXT NOT NULL DEFAULT '';
-
--- Helper index for the chain walker. SQLite's audit_events uses
--- created_at_unix (the postgres companion stores TIMESTAMPTZ), so the
--- index columns differ; the verifier reads in (timestamp, id)
--- ascending order and the planner picks this index for both forms.
-CREATE INDEX IF NOT EXISTS idx_audit_events_chain_walk
- ON audit_events (created_at_unix, id);
-
--- +goose Down
--- intentionally empty (pre-release, no compatibility shim)
diff --git a/db/migrations/sqlite/0039_webhook_outbox.sql b/db/migrations/sqlite/0039_webhook_outbox.sql
deleted file mode 100644
index ab68e9ad..00000000
--- a/db/migrations/sqlite/0039_webhook_outbox.sql
+++ /dev/null
@@ -1,37 +0,0 @@
--- +goose Up
--- 0039: webhook outbox + endpoints (SQLite mirror of postgres).
--- SQLite: BOOLEAN as INTEGER (0/1), JSONB as TEXT (stored as JSON),
--- TIMESTAMPTZ as TIMESTAMP. The store layer normalises both
--- backends to the same Go types.
-
-CREATE TABLE webhook_endpoints (
- id TEXT PRIMARY KEY,
- name TEXT NOT NULL UNIQUE,
- url TEXT NOT NULL,
- secret_ciphertext TEXT NOT NULL,
- event_filter TEXT NOT NULL DEFAULT '',
- allow_private INTEGER NOT NULL DEFAULT 0 CHECK (allow_private IN (0, 1)),
- enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
-);
-
-CREATE TABLE webhook_outbox (
- id TEXT PRIMARY KEY,
- endpoint_id TEXT NOT NULL REFERENCES webhook_endpoints(id) ON DELETE CASCADE,
- event_action TEXT NOT NULL,
- payload TEXT NOT NULL,
- attempt INTEGER NOT NULL DEFAULT 0,
- next_attempt_at TIMESTAMP NOT NULL,
- last_error TEXT NOT NULL DEFAULT '',
- dead INTEGER NOT NULL DEFAULT 0 CHECK (dead IN (0, 1)),
- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- delivered_at TIMESTAMP
-);
-
-CREATE INDEX idx_webhook_outbox_ready
- ON webhook_outbox (next_attempt_at)
- WHERE dead = 0 AND delivered_at IS NULL;
-
--- +goose Down
--- intentionally empty (pre-release, no compatibility shim)
diff --git a/db/migrations/sqlite/0040_invert_telemt_reachability.sql b/db/migrations/sqlite/0040_invert_telemt_reachability.sql
deleted file mode 100644
index 70254094..00000000
--- a/db/migrations/sqlite/0040_invert_telemt_reachability.sql
+++ /dev/null
@@ -1,16 +0,0 @@
--- +goose Up
--- Invert the telemt reachability flag so proto3's bool default (false)
--- correctly represents the healthy/reachable case. Previously
--- telemt_reachable=1 meant healthy; now telemt_unreachable=0 means healthy.
-ALTER TABLE telemt_runtime_current
- ADD COLUMN telemt_unreachable INTEGER NOT NULL DEFAULT 0;
-UPDATE telemt_runtime_current
- SET telemt_unreachable = CASE WHEN telemt_reachable = 0 THEN 1 ELSE 0 END;
-ALTER TABLE telemt_runtime_current DROP COLUMN telemt_reachable;
-
--- +goose Down
-ALTER TABLE telemt_runtime_current
- ADD COLUMN telemt_reachable INTEGER NOT NULL DEFAULT 1;
-UPDATE telemt_runtime_current
- SET telemt_reachable = CASE WHEN telemt_unreachable = 0 THEN 1 ELSE 0 END;
-ALTER TABLE telemt_runtime_current DROP COLUMN telemt_unreachable;
diff --git a/db/migrations/sqlite/0041_enrollment_attempts.sql b/db/migrations/sqlite/0041_enrollment_attempts.sql
deleted file mode 100644
index 9f31fc61..00000000
--- a/db/migrations/sqlite/0041_enrollment_attempts.sql
+++ /dev/null
@@ -1,38 +0,0 @@
--- +goose Up
-CREATE TABLE enrollment_attempts (
- id TEXT PRIMARY KEY,
- token_id TEXT,
- agent_id TEXT,
- mode TEXT NOT NULL CHECK (mode IN ('inbound', 'outbound')),
- client_addr TEXT,
- request_id TEXT NOT NULL,
- status TEXT NOT NULL CHECK (status IN ('in_progress', 'success', 'failed')),
- error_code TEXT,
- error_message TEXT,
- started_at TIMESTAMP NOT NULL,
- finished_at TIMESTAMP
-);
-
-CREATE INDEX idx_enrollment_attempts_token ON enrollment_attempts(token_id);
-CREATE INDEX idx_enrollment_attempts_agent ON enrollment_attempts(agent_id);
-CREATE INDEX idx_enrollment_attempts_started ON enrollment_attempts(started_at);
-
-CREATE TABLE enrollment_events (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- attempt_id TEXT NOT NULL REFERENCES enrollment_attempts(id) ON DELETE CASCADE,
- ts TIMESTAMP NOT NULL,
- step TEXT NOT NULL,
- level TEXT NOT NULL CHECK (level IN ('info', 'warn', 'error')),
- message TEXT,
- fields_json TEXT
-);
-
-CREATE INDEX idx_enrollment_events_attempt ON enrollment_events(attempt_id, ts);
-
--- +goose Down
-DROP INDEX IF EXISTS idx_enrollment_events_attempt;
-DROP TABLE IF EXISTS enrollment_events;
-DROP INDEX IF EXISTS idx_enrollment_attempts_started;
-DROP INDEX IF EXISTS idx_enrollment_attempts_agent;
-DROP INDEX IF EXISTS idx_enrollment_attempts_token;
-DROP TABLE IF EXISTS enrollment_attempts;
diff --git a/db/migrations/sqlite/0042_client_deployment_last_reset.sql b/db/migrations/sqlite/0042_client_deployment_last_reset.sql
deleted file mode 100644
index a4f816d9..00000000
--- a/db/migrations/sqlite/0042_client_deployment_last_reset.sql
+++ /dev/null
@@ -1,12 +0,0 @@
--- +goose Up
--- Phase 3: per-(client, agent) record of when the panel last applied a
--- successful quota reset. Compared against Telemt's reported
--- last_reset_epoch_secs on each ClientUsage snapshot to detect drift —
--- i.e. cases where the panel-driven reset job succeeded but Telemt's
--- persisted quota state has fallen behind (Telemt restart before
--- sidecar flush, raw curl reset out of band, etc.).
-ALTER TABLE client_deployments
- ADD COLUMN last_reset_epoch_secs INTEGER NOT NULL DEFAULT 0;
-
--- +goose Down
-ALTER TABLE client_deployments DROP COLUMN last_reset_epoch_secs;
diff --git a/db/migrations/sqlite/0043_client_usage_quota.sql b/db/migrations/sqlite/0043_client_usage_quota.sql
deleted file mode 100644
index b2745a8a..00000000
--- a/db/migrations/sqlite/0043_client_usage_quota.sql
+++ /dev/null
@@ -1,13 +0,0 @@
--- +goose Up
--- IN-H2: persist the per-(client, agent) quota counters alongside the other
--- usage fields. Previously quota_used_bytes / quota_last_reset_unix lived
--- only in the in-memory mirror, so a panel restart snapped them to 0 until
--- the next agent usage tick repopulated them.
-ALTER TABLE client_usage
- ADD COLUMN quota_used_bytes INTEGER NOT NULL DEFAULT 0;
-ALTER TABLE client_usage
- ADD COLUMN quota_last_reset_unix INTEGER NOT NULL DEFAULT 0;
-
--- +goose Down
-ALTER TABLE client_usage DROP COLUMN quota_last_reset_unix;
-ALTER TABLE client_usage DROP COLUMN quota_used_bytes;
diff --git a/db/migrations/sqlite/0044_drop_enrollment_token_value_hash.sql b/db/migrations/sqlite/0044_drop_enrollment_token_value_hash.sql
deleted file mode 100644
index c10e8188..00000000
--- a/db/migrations/sqlite/0044_drop_enrollment_token_value_hash.sql
+++ /dev/null
@@ -1,24 +0,0 @@
--- +goose Up
--- L-4: drop the dead enrollment_tokens.value_hash column (see the Postgres
--- 0044 mirror). The column is indexed, and SQLite cannot DROP an indexed
--- column inline, so rebuild the table without it (same recipe as 0021's
--- Down) and recreate the surviving fleet_group_id index. Pre-release, no
--- compatibility shim.
-DROP INDEX IF EXISTS idx_enrollment_tokens_value_hash;
-CREATE TABLE enrollment_tokens_new (
- value TEXT PRIMARY KEY,
- fleet_group_id TEXT,
- issued_at_unix INTEGER NOT NULL,
- expires_at_unix INTEGER NOT NULL,
- consumed_at_unix INTEGER,
- revoked_at_unix INTEGER
-);
-INSERT INTO enrollment_tokens_new (value, fleet_group_id, issued_at_unix, expires_at_unix, consumed_at_unix, revoked_at_unix)
- SELECT value, fleet_group_id, issued_at_unix, expires_at_unix, consumed_at_unix, revoked_at_unix
- FROM enrollment_tokens;
-DROP TABLE enrollment_tokens;
-ALTER TABLE enrollment_tokens_new RENAME TO enrollment_tokens;
-CREATE INDEX idx_enrollment_tokens_fleet_group_id ON enrollment_tokens (fleet_group_id);
-
--- +goose Down
--- intentionally empty (pre-release, no compatibility shim)
diff --git a/db/migrations/sqlite/0045_client_deployment_link_diagnostic.sql b/db/migrations/sqlite/0045_client_deployment_link_diagnostic.sql
deleted file mode 100644
index 7c1b7980..00000000
--- a/db/migrations/sqlite/0045_client_deployment_link_diagnostic.sql
+++ /dev/null
@@ -1,11 +0,0 @@
--- +goose Up
--- IN-M2: operator-facing diagnostic stamped on a successful (non-delete)
--- apply when the node returned no connection links. In that case the
--- existing connection_links are kept but may be stale after a host or
--- secret change; this column explains why so the dashboard can flag the
--- link instead of silently serving it. Empty string means "no issue".
-ALTER TABLE client_deployments
- ADD COLUMN link_diagnostic TEXT NOT NULL DEFAULT '';
-
--- +goose Down
-ALTER TABLE client_deployments DROP COLUMN link_diagnostic;
diff --git a/db/migrations/sqlite/0046_rename_connected_users_to_connections.sql b/db/migrations/sqlite/0046_rename_connected_users_to_connections.sql
deleted file mode 100644
index 426685d9..00000000
--- a/db/migrations/sqlite/0046_rename_connected_users_to_connections.sql
+++ /dev/null
@@ -1,8 +0,0 @@
--- +goose Up
--- IN-M3: rename telemt_instances.connected_users -> connections (see the
--- Postgres 0046 mirror). The column holds the telemt CurrentConnections
--- counter, not a users count. SQLite 3.25+ supports RENAME COLUMN inline.
-ALTER TABLE telemt_instances RENAME COLUMN connected_users TO connections;
-
--- +goose Down
-ALTER TABLE telemt_instances RENAME COLUMN connections TO connected_users;
diff --git a/db/migrations/sqlite/0047_drop_telemt_detail_boosts.sql b/db/migrations/sqlite/0047_drop_telemt_detail_boosts.sql
deleted file mode 100644
index 7dcd52a7..00000000
--- a/db/migrations/sqlite/0047_drop_telemt_detail_boosts.sql
+++ /dev/null
@@ -1,14 +0,0 @@
--- +goose Up
--- F4: drop the persisted detail-boost table. Detail boost is an ephemeral
--- ~10m telemetry-frequency bump for a single agent and now lives only in
--- memory on the panel (s.detailBoosts). It is intentionally not durable —
--- if the panel restarts the operator simply re-enables it.
-DROP TABLE IF EXISTS telemt_detail_boosts;
-
--- +goose Down
-CREATE TABLE IF NOT EXISTS telemt_detail_boosts (
- agent_id TEXT PRIMARY KEY,
- expires_at_unix INTEGER NOT NULL,
- updated_at_unix INTEGER NOT NULL,
- FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE
-);
diff --git a/db/migrations/sqlite/0048_jobs_status_partial.sql b/db/migrations/sqlite/0048_jobs_status_partial.sql
deleted file mode 100644
index 30e5fb89..00000000
--- a/db/migrations/sqlite/0048_jobs_status_partial.sql
+++ /dev/null
@@ -1,52 +0,0 @@
--- +goose Up
--- +goose NO TRANSACTION
--- F2: deriveJobStatus now emits a new terminal job status, "partial", for a
--- MIXED outcome (at least one target succeeded AND at least one ended
--- terminally-unsuccessful). The jobs.status CHECK constraint (born in 0026 /
--- baseline) only allowed queued/running/succeeded/failed/expired, so the very
--- first attempt to persist a partial job tripped "CHECK constraint failed:
--- status IN (...)" and the write was silently dropped — the restored job
--- reverted to its last-valid persisted status (running, or expired after TTL).
---
--- SQLite has no ALTER TABLE ... ADD/DROP CONSTRAINT, so rebuild the jobs table
--- with the widened enum using the rename → recreate → copy → drop → restore
--- pattern from 0026. job_targets is untouched: target rows never take the
--- "partial" value (it is a job-level rollup only).
-
-PRAGMA foreign_keys = OFF;
-
--- The rebuild itself (create/copy/drop/rename/index) is wrapped in an
--- explicit transaction so a crash between DROP and RENAME can never leave
--- the table dropped-but-not-renamed. PRAGMA foreign_keys cannot be toggled
--- inside a transaction, so it stays outside as its own statement (goose
--- still runs this whole file outside ITS wrapping transaction because of
--- "NO TRANSACTION" above; the BEGIN/COMMIT below is our own explicit one).
-BEGIN;
-
-CREATE TABLE jobs_new (
- id TEXT PRIMARY KEY,
- action TEXT NOT NULL,
- actor_id TEXT NOT NULL,
- status TEXT NOT NULL CHECK (status IN ('queued','running','succeeded','failed','expired','partial')),
- created_at_unix INTEGER NOT NULL,
- ttl_nanos INTEGER NOT NULL,
- idempotency_key TEXT NOT NULL UNIQUE,
- payload_json TEXT NOT NULL DEFAULT ''
-);
-
-INSERT INTO jobs_new (id, action, actor_id, status, created_at_unix, ttl_nanos, idempotency_key, payload_json)
-SELECT id, action, actor_id, status, created_at_unix, ttl_nanos, idempotency_key, payload_json FROM jobs;
-
-DROP TABLE jobs;
-ALTER TABLE jobs_new RENAME TO jobs;
-
-CREATE INDEX IF NOT EXISTS idx_jobs_created_at ON jobs (created_at_unix);
-CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs (status);
-CREATE INDEX IF NOT EXISTS idx_jobs_actor_id ON jobs (actor_id);
-
-COMMIT;
-
-PRAGMA foreign_keys = ON;
-
--- +goose Down
-SELECT 1;
diff --git a/db/migrations/sqlite/0049_agent_config_targets.sql b/db/migrations/sqlite/0049_agent_config_targets.sql
deleted file mode 100644
index 05e78811..00000000
--- a/db/migrations/sqlite/0049_agent_config_targets.sql
+++ /dev/null
@@ -1,18 +0,0 @@
--- +goose Up
--- agent_config_targets stores the operator's DESIRED Telemt config per scope.
--- scope_type is 'group' (scope_id = fleet_groups.id) or 'agent' (scope_id =
--- agents.id). sections_json is a sparse JSON object of editable config sections
--- (general/timeouts/censorship/upstreams/show_link/dc_overrides). The effective
--- config of an agent is its group target merged with its agent override.
--- SQLite: TIMESTAMPTZ as TIMESTAMP (store layer normalises to Go types).
-CREATE TABLE IF NOT EXISTS agent_config_targets (
- scope_type TEXT NOT NULL,
- scope_id TEXT NOT NULL,
- sections_json TEXT NOT NULL DEFAULT '{}',
- created_at TIMESTAMP NOT NULL,
- updated_at TIMESTAMP NOT NULL,
- PRIMARY KEY (scope_type, scope_id)
-);
-
--- +goose Down
-DROP TABLE IF EXISTS agent_config_targets;
diff --git a/db/migrations/sqlite/0050_check_parity.sql b/db/migrations/sqlite/0050_check_parity.sql
deleted file mode 100644
index ce228f38..00000000
--- a/db/migrations/sqlite/0050_check_parity.sql
+++ /dev/null
@@ -1,160 +0,0 @@
--- +goose Up
--- C1: Align SQLite schemas with Postgres.
--- 1. panel_settings: add CHECK (password_min_length >= 8 AND password_min_length <= 128)
--- 2. agents: add CHECK (length(cert_spki_sha256) IN (0, 32))
--- 3. enrollment_tokens: add FOREIGN KEY (fleet_group_id) REFERENCES fleet_groups (id)
--- ON DELETE SET NULL — a group deleted out from under outstanding tokens should
--- null their scope, not block the delete (NO ACTION) and not silently vanish;
--- matches the provider_id SET NULL convention and the documented enrollment design.
--- SQLite cannot ALTER TABLE to add CHECK or FK constraints; each table must be rebuilt.
-
-PRAGMA foreign_keys=OFF;
-
--- 1. panel_settings: add CHECK on password_min_length.
-CREATE TABLE panel_settings_new (
- scope TEXT PRIMARY KEY,
- http_public_url TEXT NOT NULL DEFAULT '',
- grpc_public_endpoint TEXT NOT NULL DEFAULT '',
- password_min_length INTEGER NOT NULL DEFAULT 10
- CHECK (password_min_length >= 8 AND password_min_length <= 128),
- retention_json TEXT NOT NULL DEFAULT '',
- geoip_json TEXT NOT NULL DEFAULT '',
- geoip_state_json TEXT NOT NULL DEFAULT '',
- updated_at_unix INTEGER NOT NULL
-);
-INSERT INTO panel_settings_new (scope, http_public_url, grpc_public_endpoint, password_min_length, retention_json, geoip_json, geoip_state_json, updated_at_unix)
- SELECT scope, http_public_url, grpc_public_endpoint, password_min_length, retention_json, geoip_json, geoip_state_json, updated_at_unix
- FROM panel_settings;
-DROP TABLE panel_settings;
-ALTER TABLE panel_settings_new RENAME TO panel_settings;
--- panel_settings has no non-PK indexes.
-
--- 2. agents: add CHECK (length(cert_spki_sha256) IN (0, 32)).
-CREATE TABLE agents_new (
- id TEXT PRIMARY KEY,
- node_name TEXT NOT NULL,
- fleet_group_id TEXT,
- version TEXT NOT NULL DEFAULT '',
- read_only INTEGER NOT NULL DEFAULT 0,
- last_seen_at_unix INTEGER NOT NULL,
- created_at_unix INTEGER NOT NULL DEFAULT 0,
- cert_issued_at_unix INTEGER,
- cert_expires_at_unix INTEGER,
- cert_serial TEXT NOT NULL DEFAULT '',
- transport_mode TEXT NOT NULL DEFAULT 'inbound'
- CHECK (transport_mode IN ('inbound', 'outbound')),
- dial_address TEXT,
- bootstrap_state TEXT NOT NULL DEFAULT 'active'
- CHECK (bootstrap_state IN ('pending', 'active', 'expired', 'revoked')),
- bootstrap_token_hash BLOB,
- bootstrap_expires_at INTEGER,
- cert_spki_sha256 BLOB NOT NULL DEFAULT x''
- CHECK (length(cert_spki_sha256) IN (0, 32)),
- FOREIGN KEY (fleet_group_id) REFERENCES fleet_groups (id)
-);
-INSERT INTO agents_new (id, node_name, fleet_group_id, version, read_only, last_seen_at_unix, created_at_unix, cert_issued_at_unix, cert_expires_at_unix, cert_serial, transport_mode, dial_address, bootstrap_state, bootstrap_token_hash, bootstrap_expires_at, cert_spki_sha256)
- SELECT id, node_name, fleet_group_id, version, read_only, last_seen_at_unix, created_at_unix, cert_issued_at_unix, cert_expires_at_unix, cert_serial, transport_mode, dial_address, bootstrap_state, bootstrap_token_hash, bootstrap_expires_at, cert_spki_sha256
- FROM agents;
-DROP TABLE agents;
-ALTER TABLE agents_new RENAME TO agents;
--- Recreate all agents indexes.
-CREATE INDEX idx_agents_cert_spki_sha256
- ON agents (cert_spki_sha256)
- WHERE length(cert_spki_sha256) > 0;
-CREATE INDEX idx_agents_fleet_group_id ON agents (fleet_group_id);
-CREATE INDEX idx_agents_last_seen_at ON agents (last_seen_at_unix);
-CREATE INDEX idx_agents_transport_mode ON agents(transport_mode);
-
--- 3. enrollment_tokens: add FK ON DELETE SET NULL for fleet_group_id.
-CREATE TABLE enrollment_tokens_new (
- value TEXT PRIMARY KEY,
- fleet_group_id TEXT,
- issued_at_unix INTEGER NOT NULL,
- expires_at_unix INTEGER NOT NULL,
- consumed_at_unix INTEGER,
- revoked_at_unix INTEGER,
- FOREIGN KEY (fleet_group_id) REFERENCES fleet_groups (id) ON DELETE SET NULL
-);
-INSERT INTO enrollment_tokens_new (value, fleet_group_id, issued_at_unix, expires_at_unix, consumed_at_unix, revoked_at_unix)
- SELECT value, fleet_group_id, issued_at_unix, expires_at_unix, consumed_at_unix, revoked_at_unix
- FROM enrollment_tokens;
-DROP TABLE enrollment_tokens;
-ALTER TABLE enrollment_tokens_new RENAME TO enrollment_tokens;
--- Recreate enrollment_tokens index.
-CREATE INDEX idx_enrollment_tokens_fleet_group_id ON enrollment_tokens (fleet_group_id);
-
-PRAGMA foreign_keys=ON;
-
--- +goose Down
--- Reverse: remove the added CHECKs and the enrollment_tokens FK.
-
-PRAGMA foreign_keys=OFF;
-
--- 1. panel_settings: drop CHECK on password_min_length.
-CREATE TABLE panel_settings_old (
- scope TEXT PRIMARY KEY,
- http_public_url TEXT NOT NULL DEFAULT '',
- grpc_public_endpoint TEXT NOT NULL DEFAULT '',
- password_min_length INTEGER NOT NULL DEFAULT 10,
- retention_json TEXT NOT NULL DEFAULT '',
- geoip_json TEXT NOT NULL DEFAULT '',
- geoip_state_json TEXT NOT NULL DEFAULT '',
- updated_at_unix INTEGER NOT NULL
-);
-INSERT INTO panel_settings_old (scope, http_public_url, grpc_public_endpoint, password_min_length, retention_json, geoip_json, geoip_state_json, updated_at_unix)
- SELECT scope, http_public_url, grpc_public_endpoint, password_min_length, retention_json, geoip_json, geoip_state_json, updated_at_unix
- FROM panel_settings;
-DROP TABLE panel_settings;
-ALTER TABLE panel_settings_old RENAME TO panel_settings;
-
--- 2. agents: drop CHECK on cert_spki_sha256.
-CREATE TABLE agents_old (
- id TEXT PRIMARY KEY,
- node_name TEXT NOT NULL,
- fleet_group_id TEXT,
- version TEXT NOT NULL DEFAULT '',
- read_only INTEGER NOT NULL DEFAULT 0,
- last_seen_at_unix INTEGER NOT NULL,
- created_at_unix INTEGER NOT NULL DEFAULT 0,
- cert_issued_at_unix INTEGER,
- cert_expires_at_unix INTEGER,
- cert_serial TEXT NOT NULL DEFAULT '',
- transport_mode TEXT NOT NULL DEFAULT 'inbound'
- CHECK (transport_mode IN ('inbound', 'outbound')),
- dial_address TEXT,
- bootstrap_state TEXT NOT NULL DEFAULT 'active'
- CHECK (bootstrap_state IN ('pending', 'active', 'expired', 'revoked')),
- bootstrap_token_hash BLOB,
- bootstrap_expires_at INTEGER,
- cert_spki_sha256 BLOB NOT NULL DEFAULT x'',
- FOREIGN KEY (fleet_group_id) REFERENCES fleet_groups (id)
-);
-INSERT INTO agents_old (id, node_name, fleet_group_id, version, read_only, last_seen_at_unix, created_at_unix, cert_issued_at_unix, cert_expires_at_unix, cert_serial, transport_mode, dial_address, bootstrap_state, bootstrap_token_hash, bootstrap_expires_at, cert_spki_sha256)
- SELECT id, node_name, fleet_group_id, version, read_only, last_seen_at_unix, created_at_unix, cert_issued_at_unix, cert_expires_at_unix, cert_serial, transport_mode, dial_address, bootstrap_state, bootstrap_token_hash, bootstrap_expires_at, cert_spki_sha256
- FROM agents;
-DROP TABLE agents;
-ALTER TABLE agents_old RENAME TO agents;
-CREATE INDEX idx_agents_cert_spki_sha256
- ON agents (cert_spki_sha256)
- WHERE length(cert_spki_sha256) > 0;
-CREATE INDEX idx_agents_fleet_group_id ON agents (fleet_group_id);
-CREATE INDEX idx_agents_last_seen_at ON agents (last_seen_at_unix);
-CREATE INDEX idx_agents_transport_mode ON agents(transport_mode);
-
--- 3. enrollment_tokens: remove FK.
-CREATE TABLE enrollment_tokens_old (
- value TEXT PRIMARY KEY,
- fleet_group_id TEXT,
- issued_at_unix INTEGER NOT NULL,
- expires_at_unix INTEGER NOT NULL,
- consumed_at_unix INTEGER,
- revoked_at_unix INTEGER
-);
-INSERT INTO enrollment_tokens_old (value, fleet_group_id, issued_at_unix, expires_at_unix, consumed_at_unix, revoked_at_unix)
- SELECT value, fleet_group_id, issued_at_unix, expires_at_unix, consumed_at_unix, revoked_at_unix
- FROM enrollment_tokens;
-DROP TABLE enrollment_tokens;
-ALTER TABLE enrollment_tokens_old RENAME TO enrollment_tokens;
-CREATE INDEX idx_enrollment_tokens_fleet_group_id ON enrollment_tokens (fleet_group_id);
-
-PRAGMA foreign_keys=ON;
diff --git a/db/migrations/sqlite/0051_client_subscription_token.sql b/db/migrations/sqlite/0051_client_subscription_token.sql
deleted file mode 100644
index bc1e5293..00000000
--- a/db/migrations/sqlite/0051_client_subscription_token.sql
+++ /dev/null
@@ -1,10 +0,0 @@
--- +goose Up
--- See postgres/0051 for rationale. SQLite UNIQUE indexes treat NULLs as
--- distinct, so multiple NULL tokens coexist without a partial predicate.
-ALTER TABLE clients ADD COLUMN subscription_token TEXT;
-CREATE UNIQUE INDEX IF NOT EXISTS clients_subscription_token_key
- ON clients (subscription_token);
-
--- +goose Down
-DROP INDEX IF EXISTS clients_subscription_token_key;
-ALTER TABLE clients DROP COLUMN subscription_token;
diff --git a/db/migrations/sqlite/0052_json_valid_checks.sql b/db/migrations/sqlite/0052_json_valid_checks.sql
deleted file mode 100644
index 3a488219..00000000
--- a/db/migrations/sqlite/0052_json_valid_checks.sql
+++ /dev/null
@@ -1,346 +0,0 @@
--- +goose Up
--- +goose NO TRANSACTION
--- M3: On PostgreSQL, JSON-shaped columns are typed JSONB and the server
--- rejects malformed JSON at write time. On SQLite these columns are plain
--- TEXT with zero validation — malformed JSON is silently accepted, which
--- diverges write-acceptance behaviour from PostgreSQL and would let a
--- corrupt row survive the offline SQLite→PostgreSQL migration only to
--- fail there instead of at the original write. SQLite has no JSONB type,
--- so the column stays TEXT; a `json_valid(...)` CHECK constraint is the
--- compensating control.
---
--- SQLite has no ALTER TABLE ADD CONSTRAINT, so each affected table is
--- rebuilt with the create/copy/drop/rename/index recipe established in
--- 0026/0048/92b357e5 (M5): every DROP TABLE / RENAME pair is wrapped in
--- its own explicit BEGIN/COMMIT so a crash between them can never leave a
--- table dropped-but-not-renamed. PRAGMA foreign_keys stays outside every
--- BEGIN/COMMIT — SQLite forbids toggling it inside a transaction — which
--- is also why this whole file runs under NO TRANSACTION.
---
--- Nine columns across eight tables are covered. Per-column CHECK/default
--- decision (all chosen so NO existing valid row can be rejected by the
--- new CHECK):
---
--- jobs.payload_json — default stays '' (permissive CHECK:
--- `payload_json = '' OR json_valid(payload_json)`). Unlike the
--- other columns, '' is a real, exercised sentinel meaning "no
--- payload" (see jobs.Repository's runPutNilPayload contract test
--- and PutJob call sites that persist a job before its payload is
--- known) — rewriting the default to '{}' would not by itself stop
--- callers from continuing to write '' explicitly, so the permissive
--- form is the only option that does not reject live traffic.
--- audit_events.details — already defaults to '{}' (valid JSON,
--- object shape); plain `CHECK (json_valid(details))`.
--- metric_snapshots."values" — NOT NULL, no default; every write goes
--- through encodeJSON(map[string]uint64), always valid JSON; plain
--- `CHECK (json_valid("values"))`.
--- integration_providers.config — already defaults to '{}' (object
--- shape) but is NOT always plain JSON: fleet.Service.
--- encryptProviderConfig seals it under the vault's
--- "integration_config" domain when a vault is configured, storing
--- a "PVS1:"/"PVS2:"/"PVS3:" prefixed ciphertext string instead
--- (internal/controlplane/secretvault). A plain json_valid CHECK
--- would reject every encrypted row, so this column gets the
--- permissive form: `CHECK (json_valid(config) OR config LIKE
--- 'PVS_:%')` — the single-char wildcard covers all three prefix
--- generations without hardcoding each one.
--- fleet_group_integrations.config — already defaults to '{}' (object
--- shape); plain `CHECK (json_valid(config))`.
--- client_deployments.connection_links — already defaults to '[]'
--- (array shape); every write goes through encodeStringArray, always
--- valid JSON; plain `CHECK (json_valid(connection_links))`.
--- discovered_clients.connection_links — same as above; plain
--- `CHECK (json_valid(connection_links))`.
--- enrollment_events.fields_json — JSONB and nullable on PostgreSQL
--- (db/migrations/postgres/0041_enrollment_attempts.sql); no default
--- on either backend. enrollment.Recorder.Event/Ingest
--- (internal/controlplane/enrollment/recorder.go) only ever calls
--- json.Marshal(fields) when len(fields) > 0 and otherwise leaves
--- FieldsJSON == ""; SQLStore.AppendEvent
--- (internal/controlplane/enrollment/sqlstore.go) maps that empty
--- string to a NULL bind param, never an empty-string column value.
--- So the column is either NULL or valid JSON, never "". CHECK must
--- explicitly admit NULL: `CHECK (fields_json IS NULL OR
--- json_valid(fields_json))` — SQLite's json_valid(NULL) already
--- evaluates to NULL, which a CHECK treats as passing, but the
--- explicit IS NULL keeps the constraint's intent unambiguous.
--- webhook_outbox.payload — JSONB and NOT NULL on PostgreSQL
--- (db/migrations/postgres/0039_webhook_outbox.sql); no default on
--- either backend. The only writer, WebhookStore.InsertOutbox
--- (internal/controlplane/storage/sqlite/webhooks.go), substitutes
--- the literal `{}` whenever the caller-supplied payload is empty,
--- so every persisted value is valid JSON; plain
--- `CHECK (json_valid(payload))`.
---
--- Every rebuilt table's INSERT..SELECT is a straight column-for-column
--- copy (no backfill needed): every existing default above is already
--- valid JSON, so no live row can trip its column's new CHECK.
-
-PRAGMA foreign_keys = OFF;
-
--- ─── jobs ────────────────────────────────────────────────────────────
-BEGIN;
-
-CREATE TABLE jobs_new (
- id TEXT PRIMARY KEY,
- action TEXT NOT NULL,
- actor_id TEXT NOT NULL,
- status TEXT NOT NULL CHECK (status IN ('queued','running','succeeded','failed','expired','partial')),
- created_at_unix INTEGER NOT NULL,
- ttl_nanos INTEGER NOT NULL,
- idempotency_key TEXT NOT NULL UNIQUE,
- payload_json TEXT NOT NULL DEFAULT ''
- CHECK (payload_json = '' OR json_valid(payload_json))
-);
-
-INSERT INTO jobs_new (id, action, actor_id, status, created_at_unix, ttl_nanos, idempotency_key, payload_json)
-SELECT id, action, actor_id, status, created_at_unix, ttl_nanos, idempotency_key, payload_json FROM jobs;
-
-DROP TABLE jobs;
-ALTER TABLE jobs_new RENAME TO jobs;
-
-CREATE INDEX IF NOT EXISTS idx_jobs_created_at ON jobs (created_at_unix);
-CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs (status);
-CREATE INDEX IF NOT EXISTS idx_jobs_actor_id ON jobs (actor_id);
-
-COMMIT;
-
--- ─── audit_events ────────────────────────────────────────────────────
-BEGIN;
-
-CREATE TABLE audit_events_new (
- id TEXT PRIMARY KEY,
- actor_id TEXT NOT NULL,
- action TEXT NOT NULL,
- target_id TEXT NOT NULL,
- created_at_unix INTEGER NOT NULL,
- details TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(details)),
- prev_hash TEXT NOT NULL DEFAULT '',
- event_hash TEXT NOT NULL DEFAULT ''
-);
-
-INSERT INTO audit_events_new (id, actor_id, action, target_id, created_at_unix, details, prev_hash, event_hash)
-SELECT id, actor_id, action, target_id, created_at_unix, details, prev_hash, event_hash FROM audit_events;
-
-DROP TABLE audit_events;
-ALTER TABLE audit_events_new RENAME TO audit_events;
-
-CREATE INDEX IF NOT EXISTS idx_audit_events_chain_walk ON audit_events (created_at_unix, id);
-CREATE INDEX IF NOT EXISTS idx_audit_events_created_at ON audit_events (created_at_unix);
-
-COMMIT;
-
--- ─── metric_snapshots ────────────────────────────────────────────────
--- `values` is a reserved keyword in SQLite; the identifier stays quoted.
-BEGIN;
-
-CREATE TABLE metric_snapshots_new (
- id TEXT PRIMARY KEY,
- agent_id TEXT NOT NULL,
- instance_id TEXT NOT NULL DEFAULT '',
- captured_at_unix INTEGER NOT NULL,
- "values" TEXT NOT NULL CHECK (json_valid("values")),
- FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE
-);
-
-INSERT INTO metric_snapshots_new (id, agent_id, instance_id, captured_at_unix, "values")
-SELECT id, agent_id, instance_id, captured_at_unix, "values" FROM metric_snapshots;
-
-DROP TABLE metric_snapshots;
-ALTER TABLE metric_snapshots_new RENAME TO metric_snapshots;
-
-CREATE INDEX IF NOT EXISTS idx_metric_snapshots_agent_captured ON metric_snapshots (agent_id, captured_at_unix);
-CREATE INDEX IF NOT EXISTS idx_metric_snapshots_captured_at ON metric_snapshots (captured_at_unix);
-
-COMMIT;
-
--- ─── integration_providers ───────────────────────────────────────────
-BEGIN;
-
-CREATE TABLE integration_providers_new (
- id TEXT PRIMARY KEY,
- kind TEXT NOT NULL,
- label TEXT NOT NULL DEFAULT '',
- -- Permissive: config is either plain JSON or a vault-sealed
- -- "PVS1:"/"PVS2:"/"PVS3:" ciphertext string (see file header note).
- config TEXT NOT NULL DEFAULT '{}'
- CHECK (json_valid(config) OR config LIKE 'PVS_:%'),
- created_at_unix INTEGER NOT NULL,
- updated_at_unix INTEGER NOT NULL
-);
-
-INSERT INTO integration_providers_new (id, kind, label, config, created_at_unix, updated_at_unix)
-SELECT id, kind, label, config, created_at_unix, updated_at_unix FROM integration_providers;
-
-DROP TABLE integration_providers;
-ALTER TABLE integration_providers_new RENAME TO integration_providers;
-
-CREATE INDEX IF NOT EXISTS idx_integration_providers_kind ON integration_providers (kind);
-
-COMMIT;
-
--- ─── fleet_group_integrations ────────────────────────────────────────
-BEGIN;
-
-CREATE TABLE fleet_group_integrations_new (
- id TEXT PRIMARY KEY,
- fleet_group_id TEXT NOT NULL,
- kind TEXT NOT NULL,
- provider_id TEXT,
- config TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(config)),
- enabled INTEGER NOT NULL DEFAULT 0,
- created_at_unix INTEGER NOT NULL,
- updated_at_unix INTEGER NOT NULL,
- FOREIGN KEY (fleet_group_id) REFERENCES fleet_groups (id) ON DELETE CASCADE,
- FOREIGN KEY (provider_id) REFERENCES integration_providers (id) ON DELETE SET NULL,
- UNIQUE (fleet_group_id, kind)
-);
-
-INSERT INTO fleet_group_integrations_new (id, fleet_group_id, kind, provider_id, config, enabled, created_at_unix, updated_at_unix)
-SELECT id, fleet_group_id, kind, provider_id, config, enabled, created_at_unix, updated_at_unix FROM fleet_group_integrations;
-
-DROP TABLE fleet_group_integrations;
-ALTER TABLE fleet_group_integrations_new RENAME TO fleet_group_integrations;
-
-CREATE INDEX IF NOT EXISTS idx_fleet_group_integrations_fleet_group_id ON fleet_group_integrations (fleet_group_id);
-CREATE INDEX IF NOT EXISTS idx_fleet_group_integrations_kind ON fleet_group_integrations (kind);
-
-COMMIT;
-
--- ─── client_deployments ──────────────────────────────────────────────
-BEGIN;
-
-CREATE TABLE client_deployments_new (
- client_id TEXT NOT NULL,
- agent_id TEXT NOT NULL,
- desired_operation TEXT NOT NULL,
- status TEXT NOT NULL,
- last_error TEXT NOT NULL DEFAULT '',
- last_applied_at_unix INTEGER,
- updated_at_unix INTEGER NOT NULL,
- connection_links TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(connection_links)),
- last_reset_epoch_secs INTEGER NOT NULL DEFAULT 0,
- link_diagnostic TEXT NOT NULL DEFAULT '',
- PRIMARY KEY (client_id, agent_id),
- FOREIGN KEY (client_id) REFERENCES clients (id) ON DELETE CASCADE,
- FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE
-);
-
-INSERT INTO client_deployments_new (
- client_id, agent_id, desired_operation, status, last_error, last_applied_at_unix,
- updated_at_unix, connection_links, last_reset_epoch_secs, link_diagnostic
-)
-SELECT client_id, agent_id, desired_operation, status, last_error, last_applied_at_unix,
- updated_at_unix, connection_links, last_reset_epoch_secs, link_diagnostic
-FROM client_deployments;
-
-DROP TABLE client_deployments;
-ALTER TABLE client_deployments_new RENAME TO client_deployments;
-
-CREATE INDEX IF NOT EXISTS idx_client_deployments_client_id ON client_deployments (client_id);
-
-COMMIT;
-
--- ─── discovered_clients ──────────────────────────────────────────────
-BEGIN;
-
-CREATE TABLE discovered_clients_new (
- id TEXT PRIMARY KEY,
- agent_id TEXT NOT NULL,
- client_name TEXT NOT NULL,
- secret TEXT NOT NULL DEFAULT '',
- status TEXT NOT NULL DEFAULT 'pending_review' CHECK (status IN ('pending_review','adopted','ignored')),
- total_octets INTEGER NOT NULL DEFAULT 0,
- current_connections INTEGER NOT NULL DEFAULT 0,
- active_unique_ips INTEGER NOT NULL DEFAULT 0,
- max_tcp_conns INTEGER NOT NULL DEFAULT 0,
- max_unique_ips INTEGER NOT NULL DEFAULT 0,
- data_quota_bytes INTEGER NOT NULL DEFAULT 0,
- expiration TEXT NOT NULL DEFAULT '',
- discovered_at_unix INTEGER NOT NULL,
- updated_at_unix INTEGER NOT NULL,
- connection_links TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(connection_links)),
- UNIQUE (agent_id, client_name),
- FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE
-);
-
-INSERT INTO discovered_clients_new (
- id, agent_id, client_name, secret, status, total_octets, current_connections,
- active_unique_ips, max_tcp_conns, max_unique_ips, data_quota_bytes, expiration,
- discovered_at_unix, updated_at_unix, connection_links
-)
-SELECT id, agent_id, client_name, secret, status, total_octets, current_connections,
- active_unique_ips, max_tcp_conns, max_unique_ips, data_quota_bytes, expiration,
- discovered_at_unix, updated_at_unix, connection_links
-FROM discovered_clients;
-
-DROP TABLE discovered_clients;
-ALTER TABLE discovered_clients_new RENAME TO discovered_clients;
-
-CREATE INDEX IF NOT EXISTS idx_discovered_clients_agent_id ON discovered_clients (agent_id);
-CREATE UNIQUE INDEX IF NOT EXISTS idx_discovered_clients_pending_unique
- ON discovered_clients (agent_id, client_name)
- WHERE status = 'pending_review';
-
-COMMIT;
-
--- ─── enrollment_events ───────────────────────────────────────────────
-BEGIN;
-
-CREATE TABLE enrollment_events_new (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- attempt_id TEXT NOT NULL REFERENCES enrollment_attempts (id) ON DELETE CASCADE,
- ts TIMESTAMP NOT NULL,
- step TEXT NOT NULL,
- level TEXT NOT NULL CHECK (level IN ('info', 'warn', 'error')),
- message TEXT,
- fields_json TEXT CHECK (fields_json IS NULL OR json_valid(fields_json))
-);
-
-INSERT INTO enrollment_events_new (id, attempt_id, ts, step, level, message, fields_json)
-SELECT id, attempt_id, ts, step, level, message, fields_json FROM enrollment_events;
-
-DROP TABLE enrollment_events;
-ALTER TABLE enrollment_events_new RENAME TO enrollment_events;
-
-CREATE INDEX IF NOT EXISTS idx_enrollment_events_attempt ON enrollment_events (attempt_id, ts);
-
-COMMIT;
-
--- ─── webhook_outbox ──────────────────────────────────────────────────
-BEGIN;
-
-CREATE TABLE webhook_outbox_new (
- id TEXT PRIMARY KEY,
- endpoint_id TEXT NOT NULL REFERENCES webhook_endpoints (id) ON DELETE CASCADE,
- event_action TEXT NOT NULL,
- payload TEXT NOT NULL CHECK (json_valid(payload)),
- attempt INTEGER NOT NULL DEFAULT 0,
- next_attempt_at TIMESTAMP NOT NULL,
- last_error TEXT NOT NULL DEFAULT '',
- dead INTEGER NOT NULL DEFAULT 0 CHECK (dead IN (0, 1)),
- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- delivered_at TIMESTAMP
-);
-
-INSERT INTO webhook_outbox_new (
- id, endpoint_id, event_action, payload, attempt, next_attempt_at,
- last_error, dead, created_at, delivered_at
-)
-SELECT id, endpoint_id, event_action, payload, attempt, next_attempt_at,
- last_error, dead, created_at, delivered_at
-FROM webhook_outbox;
-
-DROP TABLE webhook_outbox;
-ALTER TABLE webhook_outbox_new RENAME TO webhook_outbox;
-
-CREATE INDEX IF NOT EXISTS idx_webhook_outbox_ready
- ON webhook_outbox (next_attempt_at)
- WHERE dead = 0 AND delivered_at IS NULL;
-
-COMMIT;
-
-PRAGMA foreign_keys = ON;
-
--- +goose Down
-SELECT 1;
diff --git a/db/migrations/sqlite/0053_config_apply_batches.sql b/db/migrations/sqlite/0053_config_apply_batches.sql
deleted file mode 100644
index 355feb20..00000000
--- a/db/migrations/sqlite/0053_config_apply_batches.sql
+++ /dev/null
@@ -1,37 +0,0 @@
--- +goose Up
--- SQLite mirror of postgres/0053_config_apply_batches.sql. fleet_group_id
--- stays TEXT (not UUID) since fleet_groups.id is TEXT on SQLite — see
--- sqlite/0014_fleet_groups_redesign.sql's header note; UUID format is
--- enforced at the application layer, matching the
--- user_fleet_group_scopes / client_assignments convention. Timestamps use
--- the project's `*_at_unix` INTEGER convention (see jobs/metric_snapshots
--- in sqlite/0001_init.sql) rather than TIMESTAMPTZ.
-CREATE TABLE IF NOT EXISTS config_apply_batches (
- id TEXT PRIMARY KEY,
- fleet_group_id TEXT NOT NULL REFERENCES fleet_groups (id) ON DELETE CASCADE,
- mode TEXT NOT NULL CHECK (mode IN ('all_at_once', 'rolling')),
- wave_size INTEGER NOT NULL DEFAULT 1,
- expected_revision TEXT NOT NULL DEFAULT '',
- status TEXT NOT NULL CHECK (status IN ('running', 'succeeded', 'failed', 'halted')),
- created_at_unix INTEGER NOT NULL,
- updated_at_unix INTEGER NOT NULL
-);
-
-CREATE INDEX IF NOT EXISTS idx_config_apply_batches_status
- ON config_apply_batches (status);
-
-CREATE TABLE IF NOT EXISTS config_apply_batch_targets (
- batch_id TEXT NOT NULL REFERENCES config_apply_batches (id) ON DELETE CASCADE,
- agent_id TEXT NOT NULL,
- wave_index INTEGER NOT NULL,
- job_id TEXT NOT NULL DEFAULT '',
- status TEXT NOT NULL CHECK (status IN ('pending', 'running', 'succeeded', 'failed', 'skipped')),
- PRIMARY KEY (batch_id, agent_id)
-);
-
-CREATE INDEX IF NOT EXISTS idx_config_apply_batch_targets_batch_wave
- ON config_apply_batch_targets (batch_id, wave_index);
-
--- +goose Down
-DROP TABLE IF EXISTS config_apply_batch_targets;
-DROP TABLE IF EXISTS config_apply_batches;
diff --git a/db/migrations/sqlite/0054_config_apply_batch_target_message.sql b/db/migrations/sqlite/0054_config_apply_batch_target_message.sql
deleted file mode 100644
index faa7cccf..00000000
--- a/db/migrations/sqlite/0054_config_apply_batch_target_message.sql
+++ /dev/null
@@ -1,13 +0,0 @@
--- +goose Up
--- SQLite mirror of postgres/0054_config_apply_batch_target_message.sql.
--- Persists a failed (or otherwise terminal) target's message so the
--- resumable batch-status view (GET /fleet-groups/{id}/config/apply/batches/{batchId})
--- can surface the failure reason after the underlying config.apply job has
--- been evicted from the in-memory jobs store. Adding a NOT NULL column with
--- a DEFAULT is a plain ALTER TABLE on SQLite (no CHECK/FK added), so no
--- table rebuild is required.
-ALTER TABLE config_apply_batch_targets
- ADD COLUMN message TEXT NOT NULL DEFAULT '';
-
--- +goose Down
-ALTER TABLE config_apply_batch_targets DROP COLUMN message;
diff --git a/db/migrations/sqlite/0055_runtime_current_json.sql b/db/migrations/sqlite/0055_runtime_current_json.sql
deleted file mode 100644
index 8ecc5f3c..00000000
--- a/db/migrations/sqlite/0055_runtime_current_json.sql
+++ /dev/null
@@ -1,18 +0,0 @@
--- +goose Up
--- P3-3.1 (аудит #3): узкоколоночное зеркало AgentRuntime теряло ~44% полей
--- при рестарте панели. Runtime теперь хранится каноническим JSON-blob'ом
--- (runtime_json = json.Marshal(server.AgentRuntime)); реальными колонками
--- остаются только agent_id (PK/FK) и observed_at_unix (ORDER BY).
--- Pre-prod: без backfill — drop+recreate, первый же снапшот агента
--- заново наполняет таблицу (данные derived, источник — агент).
-DROP TABLE telemt_runtime_current;
-CREATE TABLE telemt_runtime_current (
- agent_id TEXT PRIMARY KEY,
- observed_at_unix INTEGER NOT NULL,
- runtime_json TEXT NOT NULL DEFAULT '',
- FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE
-);
-
--- +goose Down
--- Dev-stage: drop+recreate acceptable, no rollback.
-SELECT 1;
diff --git a/db/migrations/sqlite/0056_config_apply_batch_group_nullable.sql b/db/migrations/sqlite/0056_config_apply_batch_group_nullable.sql
deleted file mode 100644
index e5e41ea1..00000000
--- a/db/migrations/sqlite/0056_config_apply_batch_group_nullable.sql
+++ /dev/null
@@ -1,31 +0,0 @@
--- +goose Up
--- P3-3.4: зеркало postgres/0056 — fleet_group_id становится nullable
--- (NULL = agent-scoped batch-of-one). SQLite не умеет ALTER COLUMN DROP
--- NOT NULL — пересборка таблицы (приём как в 0050_check_parity.sql).
-PRAGMA foreign_keys=OFF;
-
-CREATE TABLE config_apply_batches_new (
- id TEXT PRIMARY KEY,
- fleet_group_id TEXT REFERENCES fleet_groups (id) ON DELETE CASCADE,
- mode TEXT NOT NULL CHECK (mode IN ('all_at_once', 'rolling')),
- wave_size INTEGER NOT NULL DEFAULT 1,
- expected_revision TEXT NOT NULL DEFAULT '',
- status TEXT NOT NULL CHECK (status IN ('running', 'succeeded', 'failed', 'halted')),
- created_at_unix INTEGER NOT NULL,
- updated_at_unix INTEGER NOT NULL
-);
-INSERT INTO config_apply_batches_new
- (id, fleet_group_id, mode, wave_size, expected_revision, status, created_at_unix, updated_at_unix)
-SELECT id, fleet_group_id, mode, wave_size, expected_revision, status, created_at_unix, updated_at_unix
-FROM config_apply_batches;
-DROP TABLE config_apply_batches;
-ALTER TABLE config_apply_batches_new RENAME TO config_apply_batches;
-
-CREATE INDEX IF NOT EXISTS idx_config_apply_batches_status
- ON config_apply_batches (status);
-
-PRAGMA foreign_keys=ON;
-
--- +goose Down
--- Dev-stage: drop+recreate acceptable, no rollback.
-SELECT 1;
diff --git a/db/migrations/sqlite/0057_client_usage_watermark.sql b/db/migrations/sqlite/0057_client_usage_watermark.sql
deleted file mode 100644
index dace6928..00000000
--- a/db/migrations/sqlite/0057_client_usage_watermark.sql
+++ /dev/null
@@ -1,16 +0,0 @@
--- +goose Up
--- P4 (cumulative traffic counters, audit 2026-07-02 #8): add the
--- per-(client, agent) watermark of the agent's cumulative counter:
--- agent_boot_id — reporting agent process epoch (UUID; '' until the
--- first cumulative report, e.g. discovery-seeded rows),
--- last_total_bytes — last cumulative total seen for that epoch.
--- The panel accumulates max(total - last_total, 0) into
--- traffic_used_bytes and rewrites the watermark; a new boot_id restarts
--- the epoch. Additive step: last_seq keeps working until the panel
--- cutover; it is dropped in 0058.
-ALTER TABLE client_usage ADD COLUMN agent_boot_id TEXT NOT NULL DEFAULT '';
-ALTER TABLE client_usage ADD COLUMN last_total_bytes INTEGER NOT NULL DEFAULT 0;
-
--- +goose Down
-ALTER TABLE client_usage DROP COLUMN last_total_bytes;
-ALTER TABLE client_usage DROP COLUMN agent_boot_id;
diff --git a/db/migrations/sqlite/0058_client_usage_drop_last_seq.sql b/db/migrations/sqlite/0058_client_usage_drop_last_seq.sql
deleted file mode 100644
index f310ba8a..00000000
--- a/db/migrations/sqlite/0058_client_usage_drop_last_seq.sql
+++ /dev/null
@@ -1,8 +0,0 @@
--- +goose Up
--- P4 final step: the seq-delta protocol is fully replaced by the
--- cumulative watermark (agent_boot_id, last_total_bytes) added in 0057
--- — drop the per-agent report cursor. Greenfield: no data migration.
-ALTER TABLE client_usage DROP COLUMN last_seq;
-
--- +goose Down
-ALTER TABLE client_usage ADD COLUMN last_seq INTEGER NOT NULL DEFAULT 0;
diff --git a/db/migrations/sqlite/baseline/baseline_v1.sql b/db/migrations/sqlite/baseline/baseline_v1.sql
deleted file mode 100644
index 121e12e3..00000000
--- a/db/migrations/sqlite/baseline/baseline_v1.sql
+++ /dev/null
@@ -1,701 +0,0 @@
--- baseline_v1.sql (SQLite) — autogenerated by migrate.TestRegenerateSQLiteBaseline.
--- DO NOT EDIT BY HAND. Re-run the test with -regen-baseline to update.
--- See docs/superpowers/plans/2026-05-09-baseline-migration.md
-
-CREATE TABLE "agent_certificate_recovery_grants" (
- agent_id TEXT PRIMARY KEY,
- issued_by TEXT NOT NULL,
- issued_at_unix INTEGER NOT NULL,
- expires_at_unix INTEGER NOT NULL,
- used_at_unix INTEGER,
- revoked_at_unix INTEGER,
- FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE
-);
-
-CREATE TABLE agent_config_targets (
- scope_type TEXT NOT NULL,
- scope_id TEXT NOT NULL,
- sections_json TEXT NOT NULL DEFAULT '{}',
- created_at TIMESTAMP NOT NULL,
- updated_at TIMESTAMP NOT NULL,
- PRIMARY KEY (scope_type, scope_id)
-);
-
-CREATE TABLE agent_fallback_state (
- agent_id TEXT PRIMARY KEY,
- entered_at_unix INTEGER NOT NULL,
- FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE
-);
-
-CREATE TABLE agent_revocations (
- agent_id TEXT PRIMARY KEY,
- revoked_at_unix INTEGER NOT NULL,
- cert_expires_at_unix INTEGER NOT NULL
-);
-
-CREATE TABLE "agents" (
- id TEXT PRIMARY KEY,
- node_name TEXT NOT NULL,
- fleet_group_id TEXT,
- version TEXT NOT NULL DEFAULT '',
- read_only INTEGER NOT NULL DEFAULT 0,
- last_seen_at_unix INTEGER NOT NULL,
- created_at_unix INTEGER NOT NULL DEFAULT 0,
- cert_issued_at_unix INTEGER,
- cert_expires_at_unix INTEGER,
- cert_serial TEXT NOT NULL DEFAULT '',
- transport_mode TEXT NOT NULL DEFAULT 'inbound'
- CHECK (transport_mode IN ('inbound', 'outbound')),
- dial_address TEXT,
- bootstrap_state TEXT NOT NULL DEFAULT 'active'
- CHECK (bootstrap_state IN ('pending', 'active', 'expired', 'revoked')),
- bootstrap_token_hash BLOB,
- bootstrap_expires_at INTEGER,
- cert_spki_sha256 BLOB NOT NULL DEFAULT x''
- CHECK (length(cert_spki_sha256) IN (0, 32)),
- FOREIGN KEY (fleet_group_id) REFERENCES fleet_groups (id)
-);
-
-CREATE TABLE "audit_events" (
- id TEXT PRIMARY KEY,
- actor_id TEXT NOT NULL,
- action TEXT NOT NULL,
- target_id TEXT NOT NULL,
- created_at_unix INTEGER NOT NULL,
- details TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(details)),
- prev_hash TEXT NOT NULL DEFAULT '',
- event_hash TEXT NOT NULL DEFAULT ''
-);
-
-CREATE TABLE certificate_authority (
- scope TEXT PRIMARY KEY,
- ca_pem TEXT NOT NULL,
- private_key_pem TEXT NOT NULL,
- updated_at_unix INTEGER NOT NULL
-);
-
-CREATE TABLE "client_assignments" (
- id TEXT PRIMARY KEY,
- client_id TEXT NOT NULL,
- target_type TEXT NOT NULL,
- fleet_group_id TEXT,
- agent_id TEXT,
- created_at_unix INTEGER NOT NULL,
- FOREIGN KEY (client_id) REFERENCES clients (id) ON DELETE CASCADE,
- FOREIGN KEY (fleet_group_id) REFERENCES fleet_groups (id) ON DELETE SET NULL,
- FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE SET NULL
-);
-
-CREATE TABLE "client_deployments" (
- client_id TEXT NOT NULL,
- agent_id TEXT NOT NULL,
- desired_operation TEXT NOT NULL,
- status TEXT NOT NULL,
- last_error TEXT NOT NULL DEFAULT '',
- last_applied_at_unix INTEGER,
- updated_at_unix INTEGER NOT NULL,
- connection_links TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(connection_links)),
- last_reset_epoch_secs INTEGER NOT NULL DEFAULT 0,
- link_diagnostic TEXT NOT NULL DEFAULT '',
- PRIMARY KEY (client_id, agent_id),
- FOREIGN KEY (client_id) REFERENCES clients (id) ON DELETE CASCADE,
- FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE
-);
-
-CREATE TABLE client_ip_history (
- agent_id TEXT NOT NULL,
- client_id TEXT NOT NULL,
- ip_address TEXT NOT NULL,
- first_seen_unix INTEGER NOT NULL,
- last_seen_unix INTEGER NOT NULL,
- PRIMARY KEY (agent_id, client_id, ip_address)
-);
-
-CREATE TABLE client_usage (
- client_id TEXT NOT NULL,
- agent_id TEXT NOT NULL,
- traffic_used_bytes INTEGER NOT NULL DEFAULT 0,
- unique_ips_used INTEGER NOT NULL DEFAULT 0,
- active_tcp_conns INTEGER NOT NULL DEFAULT 0,
- active_unique_ips INTEGER NOT NULL DEFAULT 0,
- observed_at_unix INTEGER NOT NULL, quota_used_bytes INTEGER NOT NULL DEFAULT 0, quota_last_reset_unix INTEGER NOT NULL DEFAULT 0, agent_boot_id TEXT NOT NULL DEFAULT '', last_total_bytes INTEGER NOT NULL DEFAULT 0,
- PRIMARY KEY (client_id, agent_id),
- FOREIGN KEY (client_id) REFERENCES clients (id) ON DELETE CASCADE,
- FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE
-);
-
-CREATE TABLE clients (
- id TEXT PRIMARY KEY,
- name TEXT NOT NULL,
- secret_ciphertext TEXT NOT NULL,
- user_ad_tag TEXT NOT NULL,
- enabled INTEGER NOT NULL DEFAULT 1,
- max_tcp_conns INTEGER NOT NULL DEFAULT 0,
- max_unique_ips INTEGER NOT NULL DEFAULT 0,
- data_quota_bytes INTEGER NOT NULL DEFAULT 0,
- expiration_rfc3339 TEXT NOT NULL DEFAULT '',
- created_at_unix INTEGER NOT NULL,
- updated_at_unix INTEGER NOT NULL,
- deleted_at_unix INTEGER
-, subscription_token TEXT);
-
-CREATE TABLE config_apply_batch_targets (
- batch_id TEXT NOT NULL REFERENCES config_apply_batches (id) ON DELETE CASCADE,
- agent_id TEXT NOT NULL,
- wave_index INTEGER NOT NULL,
- job_id TEXT NOT NULL DEFAULT '',
- status TEXT NOT NULL CHECK (status IN ('pending', 'running', 'succeeded', 'failed', 'skipped')), message TEXT NOT NULL DEFAULT '',
- PRIMARY KEY (batch_id, agent_id)
-);
-
-CREATE TABLE "config_apply_batches" (
- id TEXT PRIMARY KEY,
- fleet_group_id TEXT REFERENCES fleet_groups (id) ON DELETE CASCADE,
- mode TEXT NOT NULL CHECK (mode IN ('all_at_once', 'rolling')),
- wave_size INTEGER NOT NULL DEFAULT 1,
- expected_revision TEXT NOT NULL DEFAULT '',
- status TEXT NOT NULL CHECK (status IN ('running', 'succeeded', 'failed', 'halted')),
- created_at_unix INTEGER NOT NULL,
- updated_at_unix INTEGER NOT NULL
-);
-
-CREATE TABLE consumed_totp (
- user_id TEXT NOT NULL,
- code TEXT NOT NULL,
- used_at_unix INTEGER NOT NULL,
- PRIMARY KEY (user_id, code)
-);
-
-CREATE TABLE cp_secrets (
- key TEXT PRIMARY KEY,
- value BLOB NOT NULL,
- updated_at_unix INTEGER NOT NULL
-);
-
-CREATE TABLE "discovered_clients" (
- id TEXT PRIMARY KEY,
- agent_id TEXT NOT NULL,
- client_name TEXT NOT NULL,
- secret TEXT NOT NULL DEFAULT '',
- status TEXT NOT NULL DEFAULT 'pending_review' CHECK (status IN ('pending_review','adopted','ignored')),
- total_octets INTEGER NOT NULL DEFAULT 0,
- current_connections INTEGER NOT NULL DEFAULT 0,
- active_unique_ips INTEGER NOT NULL DEFAULT 0,
- max_tcp_conns INTEGER NOT NULL DEFAULT 0,
- max_unique_ips INTEGER NOT NULL DEFAULT 0,
- data_quota_bytes INTEGER NOT NULL DEFAULT 0,
- expiration TEXT NOT NULL DEFAULT '',
- discovered_at_unix INTEGER NOT NULL,
- updated_at_unix INTEGER NOT NULL,
- connection_links TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(connection_links)),
- UNIQUE (agent_id, client_name),
- FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE
-);
-
-CREATE TABLE enrollment_attempts (
- id TEXT PRIMARY KEY,
- token_id TEXT,
- agent_id TEXT,
- mode TEXT NOT NULL CHECK (mode IN ('inbound', 'outbound')),
- client_addr TEXT,
- request_id TEXT NOT NULL,
- status TEXT NOT NULL CHECK (status IN ('in_progress', 'success', 'failed')),
- error_code TEXT,
- error_message TEXT,
- started_at TIMESTAMP NOT NULL,
- finished_at TIMESTAMP
-);
-
-CREATE TABLE "enrollment_events" (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- attempt_id TEXT NOT NULL REFERENCES enrollment_attempts (id) ON DELETE CASCADE,
- ts TIMESTAMP NOT NULL,
- step TEXT NOT NULL,
- level TEXT NOT NULL CHECK (level IN ('info', 'warn', 'error')),
- message TEXT,
- fields_json TEXT CHECK (fields_json IS NULL OR json_valid(fields_json))
-);
-
-CREATE TABLE "enrollment_tokens" (
- value TEXT PRIMARY KEY,
- fleet_group_id TEXT,
- issued_at_unix INTEGER NOT NULL,
- expires_at_unix INTEGER NOT NULL,
- consumed_at_unix INTEGER,
- revoked_at_unix INTEGER,
- FOREIGN KEY (fleet_group_id) REFERENCES fleet_groups (id) ON DELETE SET NULL
-);
-
-CREATE TABLE "fleet_group_integrations" (
- id TEXT PRIMARY KEY,
- fleet_group_id TEXT NOT NULL,
- kind TEXT NOT NULL,
- provider_id TEXT,
- config TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(config)),
- enabled INTEGER NOT NULL DEFAULT 0,
- created_at_unix INTEGER NOT NULL,
- updated_at_unix INTEGER NOT NULL,
- FOREIGN KEY (fleet_group_id) REFERENCES fleet_groups (id) ON DELETE CASCADE,
- FOREIGN KEY (provider_id) REFERENCES integration_providers (id) ON DELETE SET NULL,
- UNIQUE (fleet_group_id, kind)
-);
-
-CREATE TABLE fleet_groups (
- id TEXT PRIMARY KEY,
- name TEXT NOT NULL,
- created_at_unix INTEGER NOT NULL
-, label TEXT NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '', updated_at_unix INTEGER NOT NULL DEFAULT 0);
-
-CREATE TABLE goose_db_version (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- version_id INTEGER NOT NULL,
- is_applied INTEGER NOT NULL,
- tstamp TIMESTAMP DEFAULT (datetime('now'))
- );
-
-CREATE TABLE "integration_providers" (
- id TEXT PRIMARY KEY,
- kind TEXT NOT NULL,
- label TEXT NOT NULL DEFAULT '',
- -- Permissive: config is either plain JSON or a vault-sealed
- -- "PVS1:"/"PVS2:"/"PVS3:" ciphertext string (see file header note).
- config TEXT NOT NULL DEFAULT '{}'
- CHECK (json_valid(config) OR config LIKE 'PVS_:%'),
- created_at_unix INTEGER NOT NULL,
- updated_at_unix INTEGER NOT NULL
-);
-
-CREATE TABLE "job_targets" (
- job_id TEXT NOT NULL,
- agent_id TEXT NOT NULL,
- status TEXT NOT NULL CHECK (status IN ('queued','sent','acknowledged','succeeded','failed','expired')),
- result_text TEXT NOT NULL DEFAULT '',
- result_json TEXT NOT NULL DEFAULT '',
- updated_at_unix INTEGER NOT NULL,
- PRIMARY KEY (job_id, agent_id),
- FOREIGN KEY (job_id) REFERENCES jobs (id)
-);
-
-CREATE TABLE "jobs" (
- id TEXT PRIMARY KEY,
- action TEXT NOT NULL,
- actor_id TEXT NOT NULL,
- status TEXT NOT NULL CHECK (status IN ('queued','running','succeeded','failed','expired','partial')),
- created_at_unix INTEGER NOT NULL,
- ttl_nanos INTEGER NOT NULL,
- idempotency_key TEXT NOT NULL UNIQUE,
- payload_json TEXT NOT NULL DEFAULT ''
- CHECK (payload_json = '' OR json_valid(payload_json))
-);
-
-CREATE TABLE login_lockouts (
- username TEXT PRIMARY KEY,
- failures INTEGER NOT NULL DEFAULT 0,
- locked_at_unix INTEGER,
- updated_at_unix INTEGER NOT NULL
-);
-
-CREATE TABLE "metric_snapshots" (
- id TEXT PRIMARY KEY,
- agent_id TEXT NOT NULL,
- instance_id TEXT NOT NULL DEFAULT '',
- captured_at_unix INTEGER NOT NULL,
- "values" TEXT NOT NULL CHECK (json_valid("values")),
- FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE
-);
-
-CREATE TABLE "panel_settings" (
- scope TEXT PRIMARY KEY,
- http_public_url TEXT NOT NULL DEFAULT '',
- grpc_public_endpoint TEXT NOT NULL DEFAULT '',
- password_min_length INTEGER NOT NULL DEFAULT 10
- CHECK (password_min_length >= 8 AND password_min_length <= 128),
- retention_json TEXT NOT NULL DEFAULT '',
- geoip_json TEXT NOT NULL DEFAULT '',
- geoip_state_json TEXT NOT NULL DEFAULT '',
- updated_at_unix INTEGER NOT NULL
-);
-
-CREATE TABLE runtime_settings (
- name TEXT PRIMARY KEY,
- value_json TEXT NOT NULL,
- updated_at INTEGER NOT NULL,
- updated_by TEXT NOT NULL DEFAULT ''
-);
-
-CREATE TABLE "sessions" (
- id TEXT PRIMARY KEY,
- user_id TEXT NOT NULL,
- created_at_unix INTEGER NOT NULL, last_seen_at_unix INTEGER NOT NULL DEFAULT 0,
- FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
-);
-
-CREATE TABLE telemt_diagnostics_current (
- agent_id TEXT PRIMARY KEY,
- observed_at_unix INTEGER NOT NULL,
- state TEXT NOT NULL DEFAULT '',
- state_reason TEXT NOT NULL DEFAULT '',
- system_info_json TEXT NOT NULL DEFAULT '{}',
- effective_limits_json TEXT NOT NULL DEFAULT '{}',
- security_posture_json TEXT NOT NULL DEFAULT '{}',
- minimal_all_json TEXT NOT NULL DEFAULT '{}',
- me_pool_json TEXT NOT NULL DEFAULT '{}',
- dcs_json TEXT NOT NULL DEFAULT '{}',
- FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE
-);
-
-CREATE TABLE "telemt_instances" (
- id TEXT PRIMARY KEY,
- agent_id TEXT NOT NULL,
- name TEXT NOT NULL,
- version TEXT NOT NULL DEFAULT '',
- config_fingerprint TEXT NOT NULL DEFAULT '',
- connections INTEGER NOT NULL DEFAULT 0,
- read_only INTEGER NOT NULL DEFAULT 0,
- updated_at_unix INTEGER NOT NULL,
- FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE
-);
-
-CREATE TABLE telemt_runtime_current (
- agent_id TEXT PRIMARY KEY,
- observed_at_unix INTEGER NOT NULL,
- runtime_json TEXT NOT NULL DEFAULT '',
- FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE
-);
-
-CREATE TABLE telemt_runtime_dcs_current (
- agent_id TEXT NOT NULL,
- dc INTEGER NOT NULL,
- observed_at_unix INTEGER NOT NULL,
- available_endpoints INTEGER NOT NULL DEFAULT 0,
- available_pct REAL NOT NULL DEFAULT 0,
- required_writers INTEGER NOT NULL DEFAULT 0,
- alive_writers INTEGER NOT NULL DEFAULT 0,
- coverage_pct REAL NOT NULL DEFAULT 0,
- rtt_ms REAL NOT NULL DEFAULT 0,
- load REAL NOT NULL DEFAULT 0,
- PRIMARY KEY (agent_id, dc),
- FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE
-);
-
-CREATE TABLE telemt_runtime_events (
- agent_id TEXT NOT NULL,
- sequence INTEGER NOT NULL,
- observed_at_unix INTEGER NOT NULL,
- timestamp_unix INTEGER NOT NULL,
- event_type TEXT NOT NULL DEFAULT '',
- context TEXT NOT NULL DEFAULT '',
- severity TEXT NOT NULL DEFAULT '',
- PRIMARY KEY (agent_id, sequence),
- FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE
-);
-
-CREATE TABLE telemt_runtime_upstreams_current (
- agent_id TEXT NOT NULL,
- upstream_id INTEGER NOT NULL,
- observed_at_unix INTEGER NOT NULL,
- route_kind TEXT NOT NULL DEFAULT '',
- address TEXT NOT NULL DEFAULT '',
- healthy INTEGER NOT NULL DEFAULT 0,
- fails INTEGER NOT NULL DEFAULT 0,
- effective_latency_ms REAL NOT NULL DEFAULT 0,
- PRIMARY KEY (agent_id, upstream_id),
- FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE
-);
-
-CREATE TABLE telemt_security_inventory_current (
- agent_id TEXT PRIMARY KEY,
- observed_at_unix INTEGER NOT NULL,
- state TEXT NOT NULL DEFAULT '',
- state_reason TEXT NOT NULL DEFAULT '',
- enabled INTEGER NOT NULL DEFAULT 0,
- entries_total INTEGER NOT NULL DEFAULT 0,
- entries_json TEXT NOT NULL DEFAULT '[]',
- FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE
-);
-
-CREATE TABLE ts_dc_health (
- agent_id TEXT NOT NULL,
- captured_at_unix INTEGER NOT NULL,
- dc INTEGER NOT NULL,
- coverage_pct_avg REAL NOT NULL DEFAULT 0,
- coverage_pct_min REAL NOT NULL DEFAULT 0,
- rtt_ms_avg REAL NOT NULL DEFAULT 0,
- rtt_ms_max REAL NOT NULL DEFAULT 0,
- alive_writers_min INTEGER NOT NULL DEFAULT 0,
- required_writers INTEGER NOT NULL DEFAULT 0,
- load_max INTEGER NOT NULL DEFAULT 0,
- sample_count INTEGER NOT NULL DEFAULT 1,
- PRIMARY KEY (agent_id, dc, captured_at_unix)
-);
-
-CREATE TABLE ts_server_load (
- agent_id TEXT NOT NULL,
- captured_at_unix INTEGER NOT NULL,
- cpu_pct_avg REAL NOT NULL DEFAULT 0,
- cpu_pct_max REAL NOT NULL DEFAULT 0,
- mem_pct_avg REAL NOT NULL DEFAULT 0,
- mem_pct_max REAL NOT NULL DEFAULT 0,
- disk_pct_avg REAL NOT NULL DEFAULT 0,
- disk_pct_max REAL NOT NULL DEFAULT 0,
- load_1m REAL NOT NULL DEFAULT 0,
- load_5m REAL NOT NULL DEFAULT 0,
- load_15m REAL NOT NULL DEFAULT 0,
- connections_avg INTEGER NOT NULL DEFAULT 0,
- connections_max INTEGER NOT NULL DEFAULT 0,
- connections_me_avg INTEGER NOT NULL DEFAULT 0,
- connections_direct_avg INTEGER NOT NULL DEFAULT 0,
- active_users_avg INTEGER NOT NULL DEFAULT 0,
- active_users_max INTEGER NOT NULL DEFAULT 0,
- connections_total INTEGER NOT NULL DEFAULT 0,
- connections_bad_total INTEGER NOT NULL DEFAULT 0,
- handshake_timeouts_total INTEGER NOT NULL DEFAULT 0,
- dc_coverage_min_pct REAL NOT NULL DEFAULT 0,
- dc_coverage_avg_pct REAL NOT NULL DEFAULT 0,
- healthy_upstreams INTEGER NOT NULL DEFAULT 0,
- total_upstreams INTEGER NOT NULL DEFAULT 0,
- net_bytes_sent INTEGER NOT NULL DEFAULT 0,
- net_bytes_recv INTEGER NOT NULL DEFAULT 0,
- sample_count INTEGER NOT NULL DEFAULT 1,
- PRIMARY KEY (agent_id, captured_at_unix)
-);
-
-CREATE TABLE ts_server_load_hourly (
- agent_id TEXT NOT NULL,
- bucket_hour_unix INTEGER NOT NULL,
- cpu_pct_avg REAL,
- cpu_pct_max REAL,
- mem_pct_avg REAL,
- mem_pct_max REAL,
- connections_avg REAL,
- connections_max INTEGER,
- active_users_avg REAL,
- active_users_max INTEGER,
- dc_coverage_min REAL,
- dc_coverage_avg REAL,
- sample_count INTEGER NOT NULL DEFAULT 0,
- PRIMARY KEY (agent_id, bucket_hour_unix)
-);
-
-CREATE TABLE update_config (
- key TEXT PRIMARY KEY,
- value TEXT NOT NULL
-);
-
-CREATE TABLE user_appearance (
- user_id TEXT PRIMARY KEY,
- theme TEXT NOT NULL DEFAULT 'system',
- density TEXT NOT NULL DEFAULT 'comfortable',
- help_mode TEXT NOT NULL DEFAULT 'basic',
- updated_at_unix INTEGER NOT NULL DEFAULT 0,
- FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
-);
-
-CREATE TABLE user_fleet_group_scopes (
- user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
- fleet_group_id TEXT NOT NULL REFERENCES fleet_groups (id) ON DELETE CASCADE,
- granted_at_unix INTEGER NOT NULL DEFAULT 0,
- granted_by TEXT NOT NULL DEFAULT '',
- PRIMARY KEY (user_id, fleet_group_id)
-);
-
-CREATE TABLE users (
- id TEXT PRIMARY KEY,
- username TEXT NOT NULL UNIQUE,
- password_hash TEXT NOT NULL,
- role TEXT NOT NULL,
- totp_enabled INTEGER NOT NULL DEFAULT 0,
- totp_secret TEXT NOT NULL DEFAULT '',
- created_at_unix INTEGER NOT NULL
-);
-
-CREATE TABLE webhook_endpoints (
- id TEXT PRIMARY KEY,
- name TEXT NOT NULL UNIQUE,
- url TEXT NOT NULL,
- secret_ciphertext TEXT NOT NULL,
- event_filter TEXT NOT NULL DEFAULT '',
- allow_private INTEGER NOT NULL DEFAULT 0 CHECK (allow_private IN (0, 1)),
- enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
-);
-
-CREATE TABLE "webhook_outbox" (
- id TEXT PRIMARY KEY,
- endpoint_id TEXT NOT NULL REFERENCES webhook_endpoints (id) ON DELETE CASCADE,
- event_action TEXT NOT NULL,
- payload TEXT NOT NULL CHECK (json_valid(payload)),
- attempt INTEGER NOT NULL DEFAULT 0,
- next_attempt_at TIMESTAMP NOT NULL,
- last_error TEXT NOT NULL DEFAULT '',
- dead INTEGER NOT NULL DEFAULT 0 CHECK (dead IN (0, 1)),
- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- delivered_at TIMESTAMP
-);
-
-CREATE UNIQUE INDEX clients_subscription_token_key
- ON clients (subscription_token);
-
-CREATE UNIQUE INDEX fleet_groups_name_unique
- ON fleet_groups (name);
-
-CREATE INDEX idx_agent_fallback_state_entered_at
- ON agent_fallback_state (entered_at_unix);
-
-CREATE INDEX idx_agent_revocations_cert_expires_at_unix
- ON agent_revocations(cert_expires_at_unix);
-
-CREATE INDEX idx_agents_cert_spki_sha256
- ON agents (cert_spki_sha256)
- WHERE length(cert_spki_sha256) > 0;
-
-CREATE INDEX idx_agents_fleet_group_id ON agents (fleet_group_id);
-
-CREATE INDEX idx_agents_last_seen_at ON agents (last_seen_at_unix);
-
-CREATE INDEX idx_agents_transport_mode ON agents(transport_mode);
-
-CREATE INDEX idx_audit_events_chain_walk ON audit_events (created_at_unix, id);
-
-CREATE INDEX idx_audit_events_created_at ON audit_events (created_at_unix);
-
-CREATE INDEX idx_client_assignments_client_id ON client_assignments (client_id);
-
-CREATE INDEX idx_client_deployments_client_id ON client_deployments (client_id);
-
-CREATE INDEX idx_client_ip_client ON client_ip_history (client_id, last_seen_unix DESC);
-
-CREATE INDEX idx_client_ip_client_addr ON client_ip_history (client_id, ip_address);
-
-CREATE INDEX idx_client_ip_last_seen ON client_ip_history (last_seen_unix);
-
-CREATE INDEX idx_client_usage_agent_id
- ON client_usage (agent_id);
-
-CREATE INDEX idx_config_apply_batch_targets_batch_wave
- ON config_apply_batch_targets (batch_id, wave_index);
-
-CREATE INDEX idx_config_apply_batches_status
- ON config_apply_batches (status);
-
-CREATE INDEX idx_consumed_totp_used_at ON consumed_totp(used_at_unix);
-
-CREATE INDEX idx_discovered_clients_agent_id ON discovered_clients (agent_id);
-
-CREATE UNIQUE INDEX idx_discovered_clients_pending_unique
- ON discovered_clients (agent_id, client_name)
- WHERE status = 'pending_review';
-
-CREATE INDEX idx_enrollment_attempts_agent ON enrollment_attempts(agent_id);
-
-CREATE INDEX idx_enrollment_attempts_started ON enrollment_attempts(started_at);
-
-CREATE INDEX idx_enrollment_attempts_token ON enrollment_attempts(token_id);
-
-CREATE INDEX idx_enrollment_events_attempt ON enrollment_events (attempt_id, ts);
-
-CREATE INDEX idx_enrollment_tokens_fleet_group_id ON enrollment_tokens (fleet_group_id);
-
-CREATE INDEX idx_fleet_group_integrations_fleet_group_id ON fleet_group_integrations (fleet_group_id);
-
-CREATE INDEX idx_fleet_group_integrations_kind ON fleet_group_integrations (kind);
-
-CREATE INDEX idx_integration_providers_kind ON integration_providers (kind);
-
-CREATE INDEX idx_job_targets_agent_id ON job_targets (agent_id);
-
-CREATE INDEX idx_jobs_actor_id ON jobs (actor_id);
-
-CREATE INDEX idx_jobs_created_at ON jobs (created_at_unix);
-
-CREATE INDEX idx_jobs_status ON jobs (status);
-
-CREATE INDEX idx_login_lockouts_locked_at_unix
- ON login_lockouts(locked_at_unix);
-
-CREATE INDEX idx_metric_snapshots_agent_captured ON metric_snapshots (agent_id, captured_at_unix);
-
-CREATE INDEX idx_metric_snapshots_captured_at ON metric_snapshots (captured_at_unix);
-
-CREATE INDEX idx_sessions_created_at_unix ON sessions (created_at_unix);
-
-CREATE INDEX idx_sessions_user_id ON sessions (user_id);
-
-CREATE INDEX idx_telemt_instances_agent_id ON telemt_instances (agent_id);
-
-CREATE INDEX idx_ts_dc_health_time ON ts_dc_health (agent_id, captured_at_unix DESC);
-
-CREATE INDEX idx_ts_server_load_time ON ts_server_load (agent_id, captured_at_unix DESC);
-
-CREATE INDEX idx_user_fleet_group_scopes_fleet_group_id
- ON user_fleet_group_scopes (fleet_group_id);
-
-CREATE INDEX idx_user_fleet_group_scopes_user_id
- ON user_fleet_group_scopes (user_id);
-
-CREATE INDEX idx_webhook_outbox_ready
- ON webhook_outbox (next_attempt_at)
- WHERE dead = 0 AND delivered_at IS NULL;
-
--- Mark every historical migration as already applied so goose.Up sees a no-op.
-INSERT INTO goose_db_version (version_id, is_applied) VALUES
- (0, 1),
- (1, 1),
- (2, 1),
- (3, 1),
- (4, 1),
- (5, 1),
- (6, 1),
- (7, 1),
- (8, 1),
- (9, 1),
- (10, 1),
- (11, 1),
- (12, 1),
- (13, 1),
- (14, 1),
- (15, 1),
- (16, 1),
- (17, 1),
- (18, 1),
- (19, 1),
- (20, 1),
- (21, 1),
- (22, 1),
- (23, 1),
- (24, 1),
- (25, 1),
- (26, 1),
- (27, 1),
- (28, 1),
- (29, 1),
- (30, 1),
- (31, 1),
- (32, 1),
- (33, 1),
- (34, 1),
- (35, 1),
- (36, 1),
- (37, 1),
- (38, 1),
- (39, 1),
- (40, 1),
- (41, 1),
- (42, 1),
- (43, 1),
- (44, 1),
- (45, 1),
- (46, 1),
- (47, 1),
- (48, 1),
- (49, 1),
- (50, 1),
- (51, 1),
- (52, 1),
- (53, 1),
- (54, 1),
- (55, 1),
- (56, 1),
- (57, 1),
- (58, 1);
diff --git a/db/migrations/sqlite/baseline/embed.go b/db/migrations/sqlite/baseline/embed.go
deleted file mode 100644
index 0fa50453..00000000
--- a/db/migrations/sqlite/baseline/embed.go
+++ /dev/null
@@ -1,21 +0,0 @@
-// Package sqlitebaseline exposes the consolidated baseline_v1.sql
-// schema as an embed.FS, separate from the per-version migration FS
-// so goose's filename version-parser doesn't trip over the
-// non-numbered baseline file.
-//
-// Generated by migrate.TestRegenerateSQLiteBaseline. See
-// docs/superpowers/plans/2026-05-09-baseline-migration.md for the
-// freeze rationale.
-package sqlitebaseline
-
-import "embed"
-
-//go:embed *.sql
-var FS embed.FS
-
-// LatestVersion is the migration version represented by the baseline.
-// Fresh installs apply baseline_v1.sql and mark versions 1..LatestVersion
-// as already applied in goose_db_version; goose.Up then only runs
-// migrations > LatestVersion. Bump this constant whenever
-// TestRegenerateSQLiteBaseline writes a new file.
-const LatestVersion = 58
diff --git a/db/queries/fleet_groups.sql b/db/queries/fleet_groups.sql
index 90b9340a..8dec0a20 100644
--- a/db/queries/fleet_groups.sql
+++ b/db/queries/fleet_groups.sql
@@ -1,6 +1,6 @@
-- R-Q-03: fleet_groups — operator-managed grouping of agents +
-- enrollment-tokens + client-assignments. Note: id is UUID on
--- postgres (since migration 0014).
+-- postgres (UUID id column).
-- name: GetFleetGroup :one
SELECT id, name, label, description, created_at, updated_at
diff --git a/internal/controlplane/storage/migrate/baseline_equivalence_test.go b/internal/controlplane/storage/migrate/baseline_equivalence_test.go
deleted file mode 100644
index 54a68433..00000000
--- a/internal/controlplane/storage/migrate/baseline_equivalence_test.go
+++ /dev/null
@@ -1,117 +0,0 @@
-package migrate
-
-import (
- "context"
- "database/sql"
- "path/filepath"
- "sort"
- "strings"
- "testing"
-
- sqlitebaseline "github.com/lost-coder/panvex/db/migrations/sqlite/baseline"
- sqlitestore "github.com/lost-coder/panvex/internal/controlplane/storage/sqlite"
- _ "modernc.org/sqlite"
-)
-
-// TestBaselineEquivalence_SQLite proves that the fast-path
-// (applyBaselineIfFresh inside sqlite.MigrateContext) produces a
-// schema bit-equivalent to the linear apply of every historical
-// migration. The committed baseline_v1.sql is auto-generated by
-// TestRegenerateSQLiteBaseline; this test catches the case where
-// somebody hand-edits one apply path but not the other.
-func TestBaselineEquivalence_SQLite(t *testing.T) {
- ctx := context.Background()
-
- // Path A — fast-path via the production MigrateContext. The store
- // uses the same code lifecycle real installs see (Open → MigrateContext).
- storeA, err := sqlitestore.Open(filepath.Join(t.TempDir(), "fast.db"))
- if err != nil {
- t.Fatalf("Open fast-path store: %v", err)
- }
- t.Cleanup(func() { _ = storeA.Close() })
- fastDB := storeA.DB()
- fastSchema, err := schemaFingerprint(ctx, fastDB)
- if err != nil {
- t.Fatalf("dump fast schema: %v", err)
- }
-
- // Path B — apply baseline_v1.sql directly to a virgin DB. This
- // must produce the same schema, since the file IS the consolidated
- // migration output the production code applies. Sanity check that
- // the embed.FS round-trips properly and the SQL is executable.
- baseSQL, err := sqlitebaseline.FS.ReadFile("baseline_v1.sql")
- if err != nil {
- t.Fatalf("read baseline: %v", err)
- }
- rawDB, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "raw.db"))
- if err != nil {
- t.Fatalf("open raw db: %v", err)
- }
- t.Cleanup(func() { _ = rawDB.Close() })
- if _, err := rawDB.ExecContext(ctx, string(baseSQL)); err != nil {
- t.Fatalf("exec baseline: %v", err)
- }
- rawSchema, err := schemaFingerprint(ctx, rawDB)
- if err != nil {
- t.Fatalf("dump raw schema: %v", err)
- }
- if rawSchema != fastSchema {
- t.Errorf("schema drift between MigrateContext and raw baseline apply "+
- "(baseline_v1.sql is stale — regenerate it with: "+
- "go test -run TestRegenerateSQLiteBaseline -regen-baseline ./internal/controlplane/storage/migrate/...):\n"+
- " fast = %q\n raw = %q", truncate(fastSchema, 400), truncate(rawSchema, 400))
- }
-
- // Cross-check: the active path is the fast one — confirm that.
- // On a fresh DB, applyBaselineIfFresh runs first; goose then sees
- // "everything up to v39 already applied" and walks zero further
- // migrations.
- gooseVer, err := readMaxGooseVersion(ctx, fastDB)
- if err != nil {
- t.Fatalf("read goose version: %v", err)
- }
- if gooseVer != int64(sqlitebaseline.LatestVersion) {
- t.Errorf("after fast-path MigrateContext, goose_db_version max = %d, want %d",
- gooseVer, sqlitebaseline.LatestVersion)
- }
-}
-
-// schemaFingerprint produces a deterministic representation of every
-// schema object in the DB, modulo SQLite's internal sqlite_sequence
-// table (which both paths create on-demand for AUTOINCREMENT rows
-// and may diverge by timing).
-func schemaFingerprint(ctx context.Context, db *sql.DB) (string, error) {
- rows, err := db.QueryContext(ctx, `
- SELECT type, name, COALESCE(sql, '')
- FROM sqlite_master
- WHERE name NOT LIKE 'sqlite_%'
- ORDER BY type, name
- `)
- if err != nil {
- return "", err
- }
- defer rows.Close()
- var lines []string
- for rows.Next() {
- var typeName, name, ddl string
- if err := rows.Scan(&typeName, &name, &ddl); err != nil {
- return "", err
- }
- lines = append(lines, typeName+"\x1f"+name+"\x1f"+strings.TrimSpace(ddl))
- }
- if err := rows.Err(); err != nil {
- return "", err
- }
- sort.Strings(lines)
- return strings.Join(lines, "\n"), nil
-}
-
-func readMaxGooseVersion(ctx context.Context, db *sql.DB) (int64, error) {
- var v int64
- if err := db.QueryRowContext(ctx,
- "SELECT COALESCE(MAX(version_id), 0) FROM goose_db_version WHERE is_applied = 1",
- ).Scan(&v); err != nil {
- return 0, err
- }
- return v, nil
-}
diff --git a/internal/controlplane/storage/migrate/baseline_regen_test.go b/internal/controlplane/storage/migrate/baseline_regen_test.go
deleted file mode 100644
index 0e04eb3c..00000000
--- a/internal/controlplane/storage/migrate/baseline_regen_test.go
+++ /dev/null
@@ -1,212 +0,0 @@
-package migrate
-
-import (
- "context"
- "database/sql"
- "flag"
- "fmt"
- "os"
- "path/filepath"
- "sort"
- "strings"
- "testing"
-
- sqlitemigrations "github.com/lost-coder/panvex/db/migrations/sqlite"
- "github.com/pressly/goose/v3"
- _ "modernc.org/sqlite"
-)
-
-// regenBaseline toggles overwriting the committed
-// db/migrations/sqlite/baseline_v1.sql with whatever sqlite_master
-// returns after applying every historical migration. Operators run
-// this whenever migrations are added or removed; on a normal `go test`
-// run we still execute the dump and compare against the committed
-// file so a missed update fails CI.
-var regenBaseline = flag.Bool("regen-baseline", false, "rewrite db/migrations/sqlite/baseline_v1.sql from current migrations")
-
-// TestRegenerateSQLiteBaseline produces baseline_v1.sql by replaying
-// the embedded migrations against a fresh in-memory SQLite database.
-// Without -regen-baseline it compares the produced SQL to the
-// committed file; with the flag it overwrites the file. The intent is
-// that operators editing migrations run this test once, commit the
-// regenerated baseline, and CI then re-runs it in compare mode to
-// catch stale baselines on subsequent PRs.
-func TestRegenerateSQLiteBaseline(t *testing.T) {
- ctx := context.Background()
- dir := t.TempDir()
- dbPath := filepath.Join(dir, "regen.db")
- db, err := sql.Open("sqlite", dbPath)
- if err != nil {
- t.Fatalf("sql.Open: %v", err)
- }
- t.Cleanup(func() { _ = db.Close() })
- if _, err := db.ExecContext(ctx, "PRAGMA foreign_keys = ON"); err != nil {
- t.Fatalf("enable FK: %v", err)
- }
-
- // Run every migration via goose, then dump.
- goose.SetBaseFS(sqlitemigrations.FS)
- if err := goose.SetDialect("sqlite3"); err != nil {
- t.Fatalf("set dialect: %v", err)
- }
- if err := goose.UpContext(ctx, db, "."); err != nil {
- t.Fatalf("goose up: %v", err)
- }
-
- dump, err := dumpSQLiteSchema(ctx, db)
- if err != nil {
- t.Fatalf("dump schema: %v", err)
- }
-
- baselinePath := filepath.Join(repoRootForTest(t), "db", "migrations", "sqlite", "baseline", "baseline_v1.sql")
- if err := os.MkdirAll(filepath.Dir(baselinePath), 0o750); err != nil {
- t.Fatalf("mkdir baseline dir: %v", err)
- }
-
- if *regenBaseline {
- if err := os.WriteFile(baselinePath, []byte(dump), 0o600); err != nil {
- t.Fatalf("write baseline: %v", err)
- }
- t.Logf("rewrote %s (%d bytes)", baselinePath, len(dump))
- return
- }
-
- committed, err := os.ReadFile(baselinePath)
- if err != nil {
- // First-time generation: write the file and pass. Tests on
- // subsequent runs will fail-on-diff if migrations changed
- // without a baseline refresh.
- if !os.IsNotExist(err) {
- t.Fatalf("read baseline: %v", err)
- }
- if err := os.WriteFile(baselinePath, []byte(dump), 0o600); err != nil {
- t.Fatalf("write first-time baseline: %v", err)
- }
- t.Logf("first-time wrote %s (%d bytes)", baselinePath, len(dump))
- return
- }
- if string(committed) != dump {
- t.Fatalf("baseline drift: db/migrations/sqlite/baseline/baseline_v1.sql is out of sync with current migrations.\n"+
- "Re-run with -regen-baseline:\n"+
- " go test -run TestRegenerateSQLiteBaseline -regen-baseline ./internal/controlplane/storage/migrate/...\n"+
- "diff (first 200 chars committed vs got):\n committed: %q\n got: %q",
- truncate(string(committed), 200), truncate(dump, 200))
- }
-}
-
-// dumpSQLiteSchema produces a deterministic CREATE-statement dump of
-// every persistent schema object plus the goose_db_version table data
-// rows so the baseline can be applied to a fresh DB and the
-// subsequent goose.Up call sees "everything 1..N already applied".
-func dumpSQLiteSchema(ctx context.Context, db *sql.DB) (string, error) {
- rows, err := db.QueryContext(ctx, `
- SELECT type, name, sql
- FROM sqlite_master
- WHERE sql IS NOT NULL
- AND name NOT LIKE 'sqlite_%'
- ORDER BY
- CASE type WHEN 'table' THEN 0
- WHEN 'index' THEN 1
- WHEN 'view' THEN 2
- WHEN 'trigger' THEN 3
- ELSE 4 END,
- name
- `)
- if err != nil {
- return "", err
- }
- defer rows.Close()
-
- var (
- statements []string
- preface = "-- baseline_v1.sql (SQLite) — autogenerated by migrate.TestRegenerateSQLiteBaseline.\n" +
- "-- DO NOT EDIT BY HAND. Re-run the test with -regen-baseline to update.\n" +
- "-- See docs/superpowers/plans/2026-05-09-baseline-migration.md\n\n"
- )
- for rows.Next() {
- var typeName, name, ddl string
- if err := rows.Scan(&typeName, &name, &ddl); err != nil {
- return "", err
- }
- // SQLite stores the original CREATE without trailing semicolon;
- // add one for portable execution.
- statements = append(statements, strings.TrimRight(ddl, " \t\n;")+";")
- }
- if err := rows.Err(); err != nil {
- return "", err
- }
-
- // Append the goose_db_version rows so a fresh apply marks every
- // historical migration as already-run. The columns are (id,
- // version_id, is_applied, tstamp). We INSERT version_id only +
- // is_applied=1; id and tstamp default to AUTOINCREMENT / NOW.
- versions, err := dumpSQLiteGooseVersions(ctx, db)
- if err != nil {
- return "", err
- }
- statements = append(statements, versions)
-
- return preface + strings.Join(statements, "\n\n") + "\n", nil
-}
-
-func dumpSQLiteGooseVersions(ctx context.Context, db *sql.DB) (string, error) {
- rows, err := db.QueryContext(ctx, `SELECT version_id FROM goose_db_version WHERE is_applied = 1 ORDER BY version_id`)
- if err != nil {
- return "", err
- }
- defer rows.Close()
- var ids []int64
- for rows.Next() {
- var v int64
- if err := rows.Scan(&v); err != nil {
- return "", err
- }
- ids = append(ids, v)
- }
- if err := rows.Err(); err != nil {
- return "", err
- }
- sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
- if len(ids) == 0 {
- return "", nil
- }
- var b strings.Builder
- b.WriteString("-- Mark every historical migration as already applied so goose.Up sees a no-op.\n")
- b.WriteString("INSERT INTO goose_db_version (version_id, is_applied) VALUES\n")
- for i, v := range ids {
- if i > 0 {
- b.WriteString(",\n")
- }
- fmt.Fprintf(&b, " (%d, 1)", v)
- }
- b.WriteString(";")
- return b.String(), nil
-}
-
-func truncate(s string, n int) string {
- if len(s) <= n {
- return s
- }
- return s[:n] + "…"
-}
-
-// repoRootForTest walks up from the test working directory until it
-// finds go.mod. Tests run with cwd = the package dir, so this lets
-// us reach into db/migrations/ without hard-coding a path.
-func repoRootForTest(t *testing.T) string {
- t.Helper()
- dir, err := os.Getwd()
- if err != nil {
- t.Fatalf("getwd: %v", err)
- }
- for {
- if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
- return dir
- }
- parent := filepath.Dir(dir)
- if parent == dir {
- t.Fatalf("could not find go.mod walking up from cwd")
- }
- dir = parent
- }
-}
diff --git a/internal/controlplane/storage/migrate/migration_parity_lint_test.go b/internal/controlplane/storage/migrate/migration_parity_lint_test.go
new file mode 100644
index 00000000..e6710c45
--- /dev/null
+++ b/internal/controlplane/storage/migrate/migration_parity_lint_test.go
@@ -0,0 +1,197 @@
+package migrate_test
+
+import (
+ "fmt"
+ "io/fs"
+ "regexp"
+ "sort"
+ "strconv"
+ "strings"
+ "testing"
+ "testing/fstest"
+
+ pgmigrations "github.com/lost-coder/panvex/db/migrations/postgres"
+ sqlitemigrations "github.com/lost-coder/panvex/db/migrations/sqlite"
+)
+
+// squashedHistoryCeiling — последняя goose-версия, вошедшая в P9-squash
+// (0001_init.sql консолидирует 0001..0058). БД, созданные ДО squash, несут
+// в goose_db_version версии 1..58, а goose применяет только версии выше
+// текущего максимума: новый файл с номером из диапазона 2..58 на такой БД
+// молча НЕ применился бы. Поэтому новые миграции обязаны нумероваться
+// строго выше ceiling — этот линт делает ошибку нумерации падением CI, а
+// не продовым сюрпризом.
+const squashedHistoryCeiling = 58
+
+// dialectOnlyMarker помечает миграцию, осознанно существующую только в
+// одном дереве (пример до squash: json_valid-CHECKи нужны только SQLite,
+// JSONB валидирует сам). Номер при этом РЕЗЕРВИРУЕТСЯ в обоих деревьях —
+// второе дерево не может занять его другой миграцией.
+const dialectOnlyMarker = "-- dialect-only:"
+
+var migrationNameRE = regexp.MustCompile(`^(\d{4})_(.+)\.sql$`)
+
+type migrationFile struct {
+ version int
+ title string // имя файла без NNNN_ и .sql
+ body string
+}
+
+// TestMigrationTreesAreParityLocked (закрывает класс дрейфа C9/H6 и
+// «0052-коллизию»: до squash оба дерева держали РАЗНЫЕ миграции под одним
+// номером). Правила:
+// 1. 0001_init.sql существует в обоих деревьях;
+// 2. номера 2..squashedHistoryCeiling запрещены (см. константу);
+// 3. одинаковый номер в обоих деревьях => одинаковое название файла;
+// 4. номер только в одном дереве => файл обязан содержать строку
+// `-- dialect-only: <причина>`.
+func TestMigrationTreesAreParityLocked(t *testing.T) {
+ sqliteTree := readMigrationTree(t, sqlitemigrations.FS, "sqlite")
+ pgTree := readMigrationTree(t, pgmigrations.FS, "postgres")
+ for _, problem := range checkMigrationParity(sqliteTree, pgTree) {
+ t.Error(problem)
+ }
+}
+
+func readMigrationTree(t *testing.T, fsys fs.FS, label string) map[int]migrationFile {
+ t.Helper()
+ tree := map[int]migrationFile{}
+ entries, err := fs.ReadDir(fsys, ".")
+ if err != nil {
+ t.Fatalf("%s: read dir: %v", label, err)
+ }
+ for _, e := range entries {
+ if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") {
+ continue
+ }
+ m := migrationNameRE.FindStringSubmatch(e.Name())
+ if m == nil {
+ t.Errorf("%s: %s does not match NNNN_title.sql", label, e.Name())
+ continue
+ }
+ // The regexp guarantees m[1] is exactly four digits, so Atoi cannot fail.
+ version, _ := strconv.Atoi(m[1])
+ if prev, dup := tree[version]; dup {
+ t.Errorf("%s: duplicate version %04d (%s vs %s)", label, version, prev.title, m[2])
+ continue
+ }
+ raw, err := fs.ReadFile(fsys, e.Name())
+ if err != nil {
+ t.Fatalf("%s: read %s: %v", label, e.Name(), err)
+ }
+ tree[version] = migrationFile{version: version, title: m[2], body: string(raw)}
+ }
+ return tree
+}
+
+func checkMigrationParity(sqliteTree, pgTree map[int]migrationFile) []string {
+ var problems []string
+ if _, ok := sqliteTree[1]; !ok {
+ problems = append(problems, "sqlite: 0001_init.sql is missing")
+ }
+ if _, ok := pgTree[1]; !ok {
+ problems = append(problems, "postgres: 0001_init.sql is missing")
+ }
+
+ versions := map[int]bool{}
+ for v := range sqliteTree {
+ versions[v] = true
+ }
+ for v := range pgTree {
+ versions[v] = true
+ }
+ sorted := make([]int, 0, len(versions))
+ for v := range versions {
+ sorted = append(sorted, v)
+ }
+ sort.Ints(sorted)
+
+ for _, v := range sorted {
+ if v != 1 && v <= squashedHistoryCeiling {
+ problems = append(problems, fmt.Sprintf(
+ "version %04d is inside the squashed range 2..%d: pre-squash DBs already record it as applied and goose would silently skip the file; renumber to >= %04d",
+ v, squashedHistoryCeiling, squashedHistoryCeiling+1))
+ continue
+ }
+ s, inSQLite := sqliteTree[v]
+ p, inPG := pgTree[v]
+ switch {
+ case inSQLite && inPG:
+ if s.title != p.title {
+ problems = append(problems, fmt.Sprintf(
+ "version %04d means different things per dialect: sqlite=%q postgres=%q — same number MUST be the same logical change; give each change its own number",
+ v, s.title, p.title))
+ }
+ case inSQLite:
+ if !strings.Contains(s.body, dialectOnlyMarker) {
+ problems = append(problems, fmt.Sprintf(
+ "sqlite %04d_%s.sql has no postgres twin and no %q marker: add the twin or mark it dialect-only with a reason",
+ v, s.title, dialectOnlyMarker))
+ }
+ case inPG:
+ if !strings.Contains(p.body, dialectOnlyMarker) {
+ problems = append(problems, fmt.Sprintf(
+ "postgres %04d_%s.sql has no sqlite twin and no %q marker: add the twin or mark it dialect-only with a reason",
+ v, p.title, dialectOnlyMarker))
+ }
+ }
+ }
+ return problems
+}
+
+// --- юнит-тесты правил на синтетических деревьях -----------------------
+
+func mapTree(t *testing.T, files map[string]string) map[int]migrationFile {
+ t.Helper()
+ fsys := fstest.MapFS{}
+ for name, body := range files {
+ fsys[name] = &fstest.MapFile{Data: []byte(body)}
+ }
+ return readMigrationTree(t, fsys, "synthetic")
+}
+
+func TestParityRuleSquashedRangeForbidden(t *testing.T) {
+ s := mapTree(t, map[string]string{"0001_init.sql": "x", "0042_oops.sql": "x"})
+ p := mapTree(t, map[string]string{"0001_init.sql": "x", "0042_oops.sql": "x"})
+ problems := checkMigrationParity(s, p)
+ if len(problems) != 1 || !strings.Contains(problems[0], "squashed range") {
+ t.Fatalf("expected exactly one squashed-range problem, got %v", problems)
+ }
+}
+
+func TestParityRuleTitleMismatch(t *testing.T) {
+ s := mapTree(t, map[string]string{"0001_init.sql": "x", "0059_add_foo.sql": "x"})
+ p := mapTree(t, map[string]string{"0001_init.sql": "x", "0059_add_bar.sql": "x"})
+ problems := checkMigrationParity(s, p)
+ if len(problems) != 1 || !strings.Contains(problems[0], "different things per dialect") {
+ t.Fatalf("expected exactly one title-mismatch problem, got %v", problems)
+ }
+}
+
+func TestParityRuleMissingTwinNeedsMarker(t *testing.T) {
+ s := mapTree(t, map[string]string{"0001_init.sql": "x", "0059_sqlite_only.sql": "no marker here"})
+ p := mapTree(t, map[string]string{"0001_init.sql": "x"})
+ problems := checkMigrationParity(s, p)
+ if len(problems) != 1 || !strings.Contains(problems[0], "dialect-only") {
+ t.Fatalf("expected exactly one missing-twin problem, got %v", problems)
+ }
+}
+
+func TestParityRuleMarkerSatisfiesMissingTwin(t *testing.T) {
+ s := mapTree(t, map[string]string{
+ "0001_init.sql": "x",
+ "0059_json_checks.sql": "-- dialect-only: JSONB validates on PG, SQLite needs json_valid CHECKs\nSELECT 1;",
+ })
+ p := mapTree(t, map[string]string{"0001_init.sql": "x"})
+ if problems := checkMigrationParity(s, p); len(problems) != 0 {
+ t.Fatalf("marker must satisfy the rule, got %v", problems)
+ }
+}
+
+func TestParityRuleCleanTreesPass(t *testing.T) {
+ s := mapTree(t, map[string]string{"0001_init.sql": "x", "0059_add_foo.sql": "x"})
+ p := mapTree(t, map[string]string{"0001_init.sql": "x", "0059_add_foo.sql": "x"})
+ if problems := checkMigrationParity(s, p); len(problems) != 0 {
+ t.Fatalf("clean trees must pass, got %v", problems)
+ }
+}
diff --git a/internal/controlplane/storage/migrate/schema_sync_test.go b/internal/controlplane/storage/migrate/schema_sync_test.go
index 74af6f85..7f1e7a29 100644
--- a/internal/controlplane/storage/migrate/schema_sync_test.go
+++ b/internal/controlplane/storage/migrate/schema_sync_test.go
@@ -206,9 +206,9 @@ var acceptedTypePairs = map[[2]string]bool{
// 0041_enrollment_attempts.sql — none of those were touched by 0052).
typePairKey("jsonb", "text"): true,
// SQLite has no UUID type; fleet_groups.id and dependent FK columns
- // are UUID on PostgreSQL (postgres/0014_fleet_groups_redesign.sql)
- // but TEXT on SQLite, with UUID format enforced at the application
- // layer (see the 0014 sqlite migration header note).
+ // are UUID on PostgreSQL (fleet_groups redesign, squashed into
+ // 0001_init.sql) but TEXT on SQLite, with UUID format enforced at the
+ // application layer.
typePairKey("uuid", "text"): true,
// agents.bootstrap_token_hash is BYTEA on PostgreSQL
// (postgres/0030_node_transport_mode.sql) and BLOB on SQLite
diff --git a/internal/controlplane/storage/migrateguard/migrateguard.go b/internal/controlplane/storage/migrateguard/migrateguard.go
index b31af00c..e252ad42 100644
--- a/internal/controlplane/storage/migrateguard/migrateguard.go
+++ b/internal/controlplane/storage/migrateguard/migrateguard.go
@@ -1,13 +1,12 @@
// Package migrateguard refuses to apply migrations that would silently
// destroy production data unless the operator has explicitly opted in.
//
-// Some early-life migrations (notably 0014_fleet_groups_redesign) ship
-// with `DELETE FROM` against tables that hold real production rows.
-// On a fresh DB those statements are no-ops; on a populated DB they
-// erase fleet, agent and client state that cannot be reconstructed
-// without restore-from-backup. The check below sits between the goose
-// runner and `UpContext` so the unsafe path can never be taken without
-// PANVEX_ALLOW_DESTRUCTIVE_MIGRATION=1 in the environment.
+// Historically the registry held 0014_fleet_groups_redesign (DELETE FROM
+// against fleet/agent/client tables); after the P9 squash the registry
+// ships empty and exists for future destructive migrations. The check
+// below sits between the goose runner and `UpContext` so an unsafe path
+// can never be taken without PANVEX_ALLOW_DESTRUCTIVE_MIGRATION=1 in the
+// environment.
package migrateguard
import (
@@ -43,8 +42,7 @@ const (
// another DROP/TRUNCATE/DELETE step.
//
// Per-dialect differences are handled at the CALL SITE — a benign
-// dialect (e.g. SQLite 0014, which uses ADD COLUMN + UPDATE) simply
-// does not call CheckAll. Keep the call-side wiring in
+// dialect simply does not call CheckAll. Keep the call-side wiring in
// internal/controlplane/storage/{postgres,sqlite}/migrate.go in sync
// with this list when adding a new entry.
type DestructiveMigration struct {
@@ -53,13 +51,16 @@ type DestructiveMigration struct {
}
// DestructiveMigrations is the canonical registry of destructive
-// migrations.
-var DestructiveMigrations = []DestructiveMigration{
- {
- Version: 14,
- Tables: []string{"fleet_groups", "agents", "clients"},
- },
-}
+// migrations. Empty since the P9 migration squash: 0001_init.sql
+// contains only CREATE statements, and the one historical destructive
+// entry (0014_fleet_groups_redesign) no longer ships. Keeping a stale
+// entry would be worse than useless — a post-squash fresh install
+// never records version 14 in goose_db_version, so checkOne would
+// treat the migration as "pending" forever and block every migration
+// run the moment the DB has data. Append a real entry (with its NEW
+// goose version, > squashedHistoryCeiling) whenever a future migration
+// destroys rows.
+var DestructiveMigrations = []DestructiveMigration{}
// CheckAll runs the guard for every entry in DestructiveMigrations and
// returns the first ErrBlocked it produces (or nil if none apply).
diff --git a/internal/controlplane/storage/migrateguard/migrateguard_test.go b/internal/controlplane/storage/migrateguard/migrateguard_test.go
index 44efd7e5..98ddd248 100644
--- a/internal/controlplane/storage/migrateguard/migrateguard_test.go
+++ b/internal/controlplane/storage/migrateguard/migrateguard_test.go
@@ -14,128 +14,117 @@ import (
func openSQLite(t *testing.T) *sql.DB {
t.Helper()
- dsn := "file:" + filepath.Join(t.TempDir(), "guard.db")
- db, err := sql.Open("sqlite", dsn)
+ db, err := sql.Open("sqlite", "file:"+filepath.Join(t.TempDir(), "guard.db"))
if err != nil {
t.Fatalf("sql.Open: %v", err)
}
+ db.SetMaxOpenConns(1)
t.Cleanup(func() { _ = db.Close() })
return db
}
-// schema bootstraps just enough of the production layout for the guard
-// to make a meaningful decision: the goose bookkeeping table plus the
-// three tables that DestructiveMigrations[0] guards.
-func schema(t *testing.T, db *sql.DB) {
+func mustExec(t *testing.T, db *sql.DB, query string, args ...any) {
t.Helper()
- stmts := []string{
- `CREATE TABLE goose_db_version (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- version_id BIGINT NOT NULL,
- is_applied INTEGER NOT NULL,
- tstamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
- )`,
- `CREATE TABLE fleet_groups (id TEXT PRIMARY KEY, name TEXT)`,
- `CREATE TABLE agents (id TEXT PRIMARY KEY)`,
- `CREATE TABLE clients (id TEXT PRIMARY KEY)`,
- }
- for _, s := range stmts {
- if _, err := db.ExecContext(t.Context(), s); err != nil {
- t.Fatalf("schema %q: %v", s, err)
- }
+ if _, err := db.ExecContext(context.Background(), query, args...); err != nil {
+ t.Fatalf("exec %q: %v", query, err)
}
}
-func TestCheckAll_FreshDB_NoGooseTable(t *testing.T) {
- t.Setenv(EnvAllowDestructiveMigration, "")
- db := openSQLite(t)
- if err := CheckAll(context.Background(), db, DialectSQLite, slog.Default()); err != nil {
- t.Fatalf("fresh DB should pass, got %v", err)
+// seedGooseTable creates the goose ledger and marks the given versions
+// applied — the minimum a guard probe needs to see a "not fresh" DB.
+func seedGooseTable(t *testing.T, db *sql.DB, versions ...int64) {
+ t.Helper()
+ mustExec(t, db, `CREATE TABLE goose_db_version (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ version_id INTEGER NOT NULL,
+ is_applied INTEGER NOT NULL,
+ tstamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP)`)
+ for _, v := range versions {
+ mustExec(t, db, `INSERT INTO goose_db_version (version_id, is_applied) VALUES (?, 1)`, v)
}
}
-func TestCheckAll_VersionAlreadyApplied(t *testing.T) {
- t.Setenv(EnvAllowDestructiveMigration, "")
- db := openSQLite(t)
- schema(t, db)
- // Mark version 14 as applied AND populate the tables: the migration
- // already ran, so the data here is post-erase state and must not
- // trigger the guard.
- if _, err := db.ExecContext(t.Context(), `INSERT INTO goose_db_version (version_id, is_applied) VALUES (14, 1)`); err != nil {
- t.Fatalf("seed goose_db_version: %v", err)
- }
- if _, err := db.ExecContext(t.Context(), `INSERT INTO fleet_groups (id, name) VALUES ('g1', 'x')`); err != nil {
- t.Fatalf("seed fleet_groups: %v", err)
+// syntheticEntry is a registry entry that no real migration ships —
+// the registry itself is empty since the P9 squash, so every behaviour
+// of checkOne is exercised through this synthetic value.
+var syntheticEntry = DestructiveMigration{
+ Version: 9999,
+ Tables: []string{"fleet_groups"},
+}
+
+// TestCheckAllEmptyRegistryIsNoOp pins the post-squash contract: with an
+// empty DestructiveMigrations registry, CheckAll passes on a populated
+// DB without needing PANVEX_ALLOW_DESTRUCTIVE_MIGRATION. A stale entry
+// referencing a squashed-away version would permanently block migrations
+// on any fresh-install DB that has data — this test is the regression
+// canary for that trap.
+func TestCheckAllEmptyRegistryIsNoOp(t *testing.T) {
+ if len(DestructiveMigrations) != 0 {
+ t.Fatalf("DestructiveMigrations must be empty after the P9 squash; got %d entries", len(DestructiveMigrations))
}
+ db := openSQLite(t)
+ seedGooseTable(t, db, 1)
+ mustExec(t, db, `CREATE TABLE fleet_groups (id TEXT PRIMARY KEY)`)
+ mustExec(t, db, `INSERT INTO fleet_groups (id) VALUES ('fg-1')`)
if err := CheckAll(context.Background(), db, DialectSQLite, slog.Default()); err != nil {
- t.Fatalf("already-applied should pass, got %v", err)
+ t.Fatalf("CheckAll with empty registry must pass, got: %v", err)
}
}
-func TestCheckAll_PendingButTablesEmpty(t *testing.T) {
+func TestCheckOneBlocksPopulatedUnapplied(t *testing.T) {
t.Setenv(EnvAllowDestructiveMigration, "")
db := openSQLite(t)
- schema(t, db)
- // goose table exists but version 14 is not in it; tables empty.
- if err := CheckAll(context.Background(), db, DialectSQLite, slog.Default()); err != nil {
- t.Fatalf("empty tables should pass, got %v", err)
+ seedGooseTable(t, db, 1)
+ mustExec(t, db, `CREATE TABLE fleet_groups (id TEXT PRIMARY KEY)`)
+ mustExec(t, db, `INSERT INTO fleet_groups (id) VALUES ('fg-1')`)
+
+ err := checkOne(context.Background(), db, DialectSQLite, syntheticEntry, slog.Default())
+ if !errors.Is(err, ErrBlocked) {
+ t.Fatalf("expected ErrBlocked, got: %v", err)
}
}
-func TestCheckAll_PendingAndPopulated_BlocksByDefault(t *testing.T) {
+func TestCheckOneFreshDBPasses(t *testing.T) {
t.Setenv(EnvAllowDestructiveMigration, "")
- db := openSQLite(t)
- schema(t, db)
- if _, err := db.ExecContext(t.Context(), `INSERT INTO fleet_groups (id, name) VALUES ('g1', 'production')`); err != nil {
- t.Fatalf("seed: %v", err)
- }
+ db := openSQLite(t) // no goose_db_version table at all
- err := CheckAll(context.Background(), db, DialectSQLite, slog.Default())
- if !errors.Is(err, ErrBlocked) {
- t.Fatalf("want ErrBlocked, got %v", err)
+ if err := checkOne(context.Background(), db, DialectSQLite, syntheticEntry, slog.Default()); err != nil {
+ t.Fatalf("fresh DB must pass, got: %v", err)
}
}
-func TestCheckAll_PendingAndPopulated_AllowedByEnv(t *testing.T) {
- t.Setenv(EnvAllowDestructiveMigration, "1")
+func TestCheckOneAppliedVersionPasses(t *testing.T) {
+ t.Setenv(EnvAllowDestructiveMigration, "")
db := openSQLite(t)
- schema(t, db)
- if _, err := db.ExecContext(t.Context(), `INSERT INTO agents (id) VALUES ('a1')`); err != nil {
- t.Fatalf("seed: %v", err)
- }
+ seedGooseTable(t, db, syntheticEntry.Version)
+ mustExec(t, db, `CREATE TABLE fleet_groups (id TEXT PRIMARY KEY)`)
+ mustExec(t, db, `INSERT INTO fleet_groups (id) VALUES ('fg-1')`)
- if err := CheckAll(context.Background(), db, DialectSQLite, slog.Default()); err != nil {
- t.Fatalf("opt-in should pass, got %v", err)
+ if err := checkOne(context.Background(), db, DialectSQLite, syntheticEntry, slog.Default()); err != nil {
+ t.Fatalf("already-applied version must pass, got: %v", err)
}
}
-func TestCheckAll_TableMissing_DoesNotPanic(t *testing.T) {
+func TestCheckOneEmptyTablesPass(t *testing.T) {
t.Setenv(EnvAllowDestructiveMigration, "")
db := openSQLite(t)
- // goose table exists but the data tables do not — earliest schema state.
- if _, err := db.ExecContext(t.Context(), `CREATE TABLE goose_db_version (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- version_id BIGINT NOT NULL,
- is_applied INTEGER NOT NULL,
- tstamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
- )`); err != nil {
- t.Fatal(err)
- }
- if err := CheckAll(context.Background(), db, DialectSQLite, slog.Default()); err != nil {
- t.Fatalf("missing data tables should pass, got %v", err)
+ seedGooseTable(t, db, 1)
+ mustExec(t, db, `CREATE TABLE fleet_groups (id TEXT PRIMARY KEY)`)
+
+ if err := checkOne(context.Background(), db, DialectSQLite, syntheticEntry, slog.Default()); err != nil {
+ t.Fatalf("empty destructive-target tables must pass, got: %v", err)
}
}
-func TestCheckAll_BlankEnvDoesNotCountAsOptIn(t *testing.T) {
- // Explicitly set to whitespace; should be treated as unset.
- t.Setenv(EnvAllowDestructiveMigration, " ")
+func TestCheckOneOptInAllows(t *testing.T) {
+ t.Setenv(EnvAllowDestructiveMigration, "1")
db := openSQLite(t)
- schema(t, db)
- if _, err := db.ExecContext(t.Context(), `INSERT INTO clients (id) VALUES ('c1')`); err != nil {
- t.Fatal(err)
- }
- if err := CheckAll(context.Background(), db, DialectSQLite, slog.Default()); !errors.Is(err, ErrBlocked) {
- t.Fatalf("want ErrBlocked despite whitespace-only env, got %v", err)
+ seedGooseTable(t, db, 1)
+ mustExec(t, db, `CREATE TABLE fleet_groups (id TEXT PRIMARY KEY)`)
+ mustExec(t, db, `INSERT INTO fleet_groups (id) VALUES ('fg-1')`)
+
+ if err := checkOne(context.Background(), db, DialectSQLite, syntheticEntry, slog.Default()); err != nil {
+ t.Fatalf("opt-in env must allow, got: %v", err)
}
}
diff --git a/internal/controlplane/storage/postgres/migrate.go b/internal/controlplane/storage/postgres/migrate.go
index 76e6f650..113607eb 100644
--- a/internal/controlplane/storage/postgres/migrate.go
+++ b/internal/controlplane/storage/postgres/migrate.go
@@ -39,8 +39,10 @@ func MigrateContext(ctx context.Context, db *sql.DB) error {
return err
}
// Guard runs BEFORE goose.UpContext so a destructive migration
- // (e.g., 0014_fleet_groups_redesign) cannot delete production rows
- // without an explicit operator opt-in. See migrateguard package.
+ // cannot delete production rows without an explicit operator
+ // opt-in. The registry is empty since the P9 squash (no-op), but
+ // the wiring stays so a future destructive migration only needs a
+ // registry entry. See migrateguard package.
if err := migrateguard.CheckAll(ctx, db, migrateguard.DialectPostgres, nil); err != nil {
return fmt.Errorf("postgres: %w", err)
}
diff --git a/internal/controlplane/storage/postgres/migrate_test.go b/internal/controlplane/storage/postgres/migrate_test.go
index 3838a56c..ca1f4c9a 100644
--- a/internal/controlplane/storage/postgres/migrate_test.go
+++ b/internal/controlplane/storage/postgres/migrate_test.go
@@ -44,12 +44,15 @@ func TestMigrateGoosePostgres(t *testing.T) {
if err != nil || !exists {
t.Fatalf("goose_db_version table missing: err=%v exists=%v", err, exists)
}
+ // Exactly one embedded migration after the P9 squash: 0001_init.sql.
+ // Pre-squash DBs carry versions 1..58, but this test migrates a fresh
+ // schema, so the ledger must contain exactly version 1.
var count int
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM goose_db_version WHERE is_applied = TRUE AND version_id > 0`).Scan(&count); err != nil {
t.Fatalf("count applied versions: %v", err)
}
- if count < 7 {
- t.Fatalf("expected >= 7 applied goose versions, got %d", count)
+ if count != 1 {
+ t.Fatalf("expected exactly 1 applied goose version (0001_init), got %d", count)
}
})
diff --git a/internal/controlplane/storage/sqlite/migrate.go b/internal/controlplane/storage/sqlite/migrate.go
index 0ed01282..15df4f83 100644
--- a/internal/controlplane/storage/sqlite/migrate.go
+++ b/internal/controlplane/storage/sqlite/migrate.go
@@ -1,24 +1,23 @@
// Package sqlite hosts the SQLite-backed storage.Store implementation.
// This file owns schema management — it delegates entirely to goose, which
// discovers versioned .sql migrations from an embedded FS and records applied
-// versions in the goose_db_version table. Historically this package contained
-// a hand-rolled Migrate() with a single big initialSchema string plus a long
-// tail of ensureXxxColumn / ensureXxxTable helpers that papered over SQLite's
-// lack of "ALTER TABLE ... IF NOT EXISTS"; that approach left no audit trail
-// of which migrations had run (see DF-20 / M-F8 in the security review).
+// versions in the goose_db_version table.
+//
+// History: the migration tree was squashed into a single 0001_init.sql in
+// P9 (2026-07). Databases created before the squash carry goose versions
+// 1..58 and see 0001 as already superseded; new migrations therefore MUST
+// use versions >= 0059 — the parity lint in storage/migrate enforces this.
+// The former fresh-install baseline fast-path (applyBaselineIfFresh) was
+// deleted with the squash: 0001_init.sql IS the baseline now.
package sqlite
import (
"context"
"database/sql"
- "errors"
"fmt"
- "io/fs"
- "log/slog"
"sync"
sqlitemigrations "github.com/lost-coder/panvex/db/migrations/sqlite"
- sqlitebaseline "github.com/lost-coder/panvex/db/migrations/sqlite/baseline"
"github.com/pressly/goose/v3"
)
@@ -36,19 +35,14 @@ func Migrate(db *sql.DB) error {
// MigrateContext is the context-aware variant of Migrate.
//
-// SQLite intentionally skips the migrateguard.CheckAll step: the only
-// migration currently in the destructive registry (0014) ships in a
-// non-destructive ADD COLUMN + UPDATE form on this dialect (see
-// db/migrations/sqlite/0014_fleet_groups_redesign.sql). If a future
+// SQLite intentionally skips the migrateguard.CheckAll step: the
+// destructive registry is empty since the P9 squash. If a future
// destructive migration applies to SQLite as well, wire CheckAll here
// the same way postgres/migrate.go does.
func MigrateContext(ctx context.Context, db *sql.DB) error {
gooseMu.Lock()
defer gooseMu.Unlock()
- if err := applyBaselineIfFresh(ctx, db); err != nil {
- return fmt.Errorf("sqlite: baseline: %w", err)
- }
if err := configureGoose(); err != nil {
return err
}
@@ -58,73 +52,6 @@ func MigrateContext(ctx context.Context, db *sql.DB) error {
return nil
}
-// applyBaselineIfFresh checks whether the database has never been
-// migrated (goose_db_version table missing) and, if so, applies the
-// consolidated baseline_v1.sql in a single transaction. The baseline
-// recreates the schema as it stood at version 39 and INSERTs every
-// historical version row into goose_db_version so the subsequent
-// goose.UpContext call sees a no-op for migrations 1..39 and runs
-// only the (currently zero) post-baseline migrations.
-//
-// Pre-existing databases are left untouched; the goose_db_version
-// presence check is the cheap idempotency gate.
-//
-// Wave 6.2 — see docs/superpowers/plans/2026-05-09-baseline-migration.md.
-func applyBaselineIfFresh(ctx context.Context, db *sql.DB) error {
- hasGoose, err := tableExists(ctx, db, "goose_db_version")
- if err != nil {
- return err
- }
- if hasGoose {
- // Existing install — leave the linear migration tail to goose.
- return nil
- }
- sqlBytes, err := sqlitebaseline.FS.ReadFile("baseline_v1.sql")
- if err != nil {
- // Missing baseline (e.g. a fork that stripped it) → fall back
- // to the linear apply path so goose.Up still produces a
- // correct schema. Log loudly so the operator sees the
- // degraded path; embed.FS surfaces missing files via the
- // fs.ErrNotExist sentinel.
- if errors.Is(err, fs.ErrNotExist) {
- slog.Default().Warn("sqlite baseline missing, falling back to linear migrations")
- return nil
- }
- return fmt.Errorf("read baseline: %w", err)
- }
- tx, err := db.BeginTx(ctx, nil)
- if err != nil {
- return fmt.Errorf("begin baseline tx: %w", err)
- }
- defer func() { _ = tx.Rollback() }() //nolint:errcheck // best-effort on commit-success
- // PRAGMA foreign_keys is connection-scoped and silently ignored
- // inside a transaction in SQLite, but enabling it on the
- // tx-backing connection keeps the baseline apply symmetric with
- // the per-migration apply path (each goose migration runs under
- // foreign_keys=ON via the pool-wide DSN pragmas).
- if _, err := tx.ExecContext(ctx, "PRAGMA foreign_keys = ON"); err != nil {
- return fmt.Errorf("enable FK on baseline tx: %w", err)
- }
- if _, err := tx.ExecContext(ctx, string(sqlBytes)); err != nil {
- return fmt.Errorf("exec baseline: %w", err)
- }
- if err := tx.Commit(); err != nil {
- return fmt.Errorf("commit baseline: %w", err)
- }
- return nil
-}
-
-func tableExists(ctx context.Context, db *sql.DB, name string) (bool, error) {
- var n int
- if err := db.QueryRowContext(ctx,
- "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?",
- name,
- ).Scan(&n); err != nil {
- return false, fmt.Errorf("probe %s: %w", name, err)
- }
- return n > 0, nil
-}
-
// Status writes the applied/pending migration list to stdout via goose's
// default logger. The operator invokes this through the
// `migrate-schema status` subcommand on the control-plane binary.
diff --git a/internal/controlplane/storage/sqlite/migrate_stress_test.go b/internal/controlplane/storage/sqlite/migrate_stress_test.go
deleted file mode 100644
index f3fd298d..00000000
--- a/internal/controlplane/storage/sqlite/migrate_stress_test.go
+++ /dev/null
@@ -1,380 +0,0 @@
-//go:build stress
-
-// Stress test for the goose migration chain (P2-TEST-03).
-//
-// Gated behind the `stress` build tag so it does not run in the default
-// `go test ./...` path — seeding 10k agents + 100k metric_snapshots plus the
-// full migration chain takes minutes on slower laptops and is unnecessary for
-// every CI run. Invoke it explicitly:
-//
-// go test -tags stress -count=1 -timeout 15m \
-// ./internal/controlplane/storage/sqlite -run TestMigrateStress
-//
-// What this test proves:
-// - Migrate() succeeds against a database already populated with
-// production-scale row counts at schema 0001 — not an empty DB.
-// - Every post-0001 migration (through 0011) preserves the seeded rows:
-// no ALTER/RENAME/INSERT-SELECT step silently drops data.
-// - Index-creation migrations (0007, 0008) and the 0011 column rename
-// complete within a reasonable wall-clock budget for the operator runbook.
-//
-// What this test does NOT cover:
-// - PostgreSQL. That path is exercised by postgres.TestMigrate* under its
-// own `pgtest` tag when PANVEX_POSTGRES_TEST_DSN is set.
-// - Down migrations. Goose Down is rarely used in prod and is covered by
-// the regular migrate_test.go idempotency check.
-
-package sqlite
-
-import (
- "database/sql"
- "fmt"
- "path/filepath"
- "strings"
- "testing"
- "time"
-
- // register the pure-Go SQLite driver under "sqlite" for database/sql
- _ "modernc.org/sqlite"
-)
-
-// stressSchema0001 is a trimmed copy of the tables every later Phase 2
-// migration touches. We cannot invoke goose to apply only 0001 because goose
-// has no "stop at version N" primitive from within a test; instead we
-// materialise the exact pre-0002 shape of every table we need, then let the
-// real Migrate() run the 0002..HEAD chain over the seeded data.
-//
-// Keep this schema in sync with db/migrations/sqlite/0001_init.sql for the
-// tables listed below. If a future 0001 revision changes a column, this
-// const must match — otherwise the later ALTER migrations will see the
-// "wrong" starting shape and either fail or silently miss a rename.
-const stressSchema0001 = `
-CREATE TABLE fleet_groups (
- id TEXT PRIMARY KEY,
- name TEXT NOT NULL,
- created_at_unix INTEGER NOT NULL
-);
-
-CREATE TABLE agents (
- id TEXT PRIMARY KEY,
- node_name TEXT NOT NULL,
- fleet_group_id TEXT,
- version TEXT NOT NULL DEFAULT '',
- read_only INTEGER NOT NULL DEFAULT 0,
- last_seen_at_unix INTEGER NOT NULL,
- created_at_unix INTEGER NOT NULL DEFAULT 0,
- FOREIGN KEY (fleet_group_id) REFERENCES fleet_groups (id)
-);
-
-CREATE TABLE audit_events (
- id TEXT PRIMARY KEY,
- actor_id TEXT NOT NULL,
- action TEXT NOT NULL,
- target_id TEXT NOT NULL,
- created_at_unix INTEGER NOT NULL,
- details_json TEXT NOT NULL DEFAULT '{}'
-);
-
-CREATE TABLE metric_snapshots (
- id TEXT PRIMARY KEY,
- agent_id TEXT NOT NULL,
- instance_id TEXT NOT NULL DEFAULT '',
- captured_at_unix INTEGER NOT NULL,
- values_json TEXT NOT NULL
-);
-`
-
-// The stress test scale. These numbers are the smallest that still exercise
-// the behaviours we care about — large enough that any O(N) anomaly shows up
-// in wall-clock time, small enough to finish in under 2 minutes on a laptop.
-const (
- stressAgents = 10_000
- stressMetrics = 100_000
- stressAudits = 10_000
- stressGroups = 32
- // Chunk size for multi-row INSERTs. At 5 columns per metric_snapshots row
- // this stays well under SQLite's default 32k-parameter ceiling.
- stressChunk = 2_000
-)
-
-// TestMigrateStress seeds a pre-0002 SQLite DB at production scale, runs the
-// real Migrate() chain, and asserts that (a) it completes, (b) row counts
-// are preserved through every ALTER/RENAME/INSERT-SELECT step, (c) the
-// schema-level contracts from later migrations (indexes from 0007/0008, the
-// 0011 column rename) are honoured on a non-empty DB.
-func TestMigrateStress(t *testing.T) {
- // A stress test that takes a minute to fail should give a clear reason
- // when it times out, not a truncated testing.T.Fatal.
- started := time.Now()
- defer func() {
- t.Logf("TestMigrateStress wall-clock: %s", time.Since(started))
- }()
-
- db := openStressSQLite(t)
-
- // Stage 1 — materialise schema 0001. No goose: we want the exact column
- // layout that existed before 0002..0011 ran.
- if _, err := db.Exec(stressSchema0001); err != nil {
- t.Fatalf("apply stressSchema0001: %v", err)
- }
-
- // Stage 2 — bulk-seed. Tune PRAGMAs for speed; Migrate() will pick its
- // own PRAGMAs downstream.
- applyPragmas(t, db,
- "PRAGMA journal_mode = WAL",
- "PRAGMA synchronous = OFF",
- "PRAGMA temp_store = MEMORY",
- "PRAGMA cache_size = -200000", // ~200MB page cache during seed
- )
- seedStart := time.Now()
- seedStressData(t, db)
- t.Logf("seeded %d agents + %d metric_snapshots + %d audits in %s",
- stressAgents, stressMetrics, stressAudits, time.Since(seedStart))
-
- // Capture pre-migration row counts so we can diff them after Migrate().
- // Every later migration must be data-preserving on these tables.
- pre := countTables(t, db, "fleet_groups", "agents", "audit_events", "metric_snapshots")
-
- // Stage 3 — run the real migration chain. This is the behaviour under
- // test; a panic, error, or hang here is the whole failure mode.
- migStart := time.Now()
- if err := Migrate(db); err != nil {
- t.Fatalf("Migrate() on seeded DB: %v", err)
- }
- t.Logf("Migrate() applied full chain in %s", time.Since(migStart))
-
- // Stage 4 — invariants.
- post := countTables(t, db, "fleet_groups", "agents", "audit_events", "metric_snapshots")
- for tbl, want := range pre {
- if got := post[tbl]; got != want {
- t.Errorf("row count drift on %s: pre=%d post=%d", tbl, want, got)
- }
- }
-
- // Verify the 0011 column rename actually moved data. Reading the renamed
- // column confirms both schema and row survival.
- var anyValues string
- err := db.QueryRow(`SELECT "values" FROM metric_snapshots LIMIT 1`).Scan(&anyValues)
- if err != nil {
- t.Fatalf("read metric_snapshots.values after 0011: %v", err)
- }
- if anyValues == "" {
- t.Errorf("metric_snapshots.values is empty — 0011 rename may have dropped data")
- }
- var anyDetails string
- err = db.QueryRow(`SELECT details FROM audit_events LIMIT 1`).Scan(&anyDetails)
- if err != nil {
- t.Fatalf("read audit_events.details after 0011: %v", err)
- }
- if anyDetails == "" {
- t.Errorf("audit_events.details is empty — 0011 rename may have dropped data")
- }
-
- // P2-DB-02 / DF-22 indexes must be present after 0008. On an empty DB
- // that is already covered by TestMigrateCreatesMissingFKIndexes; here we
- // assert the same holds when the index is built over real data.
- for _, idx := range []string{
- "idx_jobs_status",
- "idx_job_targets_agent_id",
- "idx_metric_snapshots_captured_at",
- "idx_enrollment_tokens_fleet_group_id",
- } {
- var name string
- if err := db.QueryRow(
- `SELECT name FROM sqlite_master WHERE type='index' AND name=?`, idx,
- ).Scan(&name); err != nil {
- t.Errorf("missing index %q after Migrate on seeded DB: %v", idx, err)
- }
- }
-
- // goose_db_version must hold one row per applied migration. A missing
- // row here means a migration ran without being recorded — the DF-20
- // failure mode we built this whole framework to eliminate.
- var versionCount int
- if err := db.QueryRow(`SELECT COUNT(*) FROM goose_db_version`).Scan(&versionCount); err != nil {
- t.Fatalf("count goose_db_version: %v", err)
- }
- // At minimum the 11 embedded migrations (0001..0011) plus goose's
- // zero-version bookkeeping row.
- if versionCount < 11 {
- t.Errorf("goose_db_version has %d rows, expected >= 11", versionCount)
- }
-
- // Re-running Migrate on the seeded+migrated DB must be a no-op. This
- // catches the case where a migration accidentally re-runs DDL on an
- // already-upgraded schema.
- if err := Migrate(db); err != nil {
- t.Fatalf("Migrate() second pass on seeded DB: %v", err)
- }
- after := countTables(t, db, "fleet_groups", "agents", "audit_events", "metric_snapshots")
- for tbl, want := range post {
- if got := after[tbl]; got != want {
- t.Errorf("row count changed on second Migrate(): %s pre=%d post=%d", tbl, want, got)
- }
- }
-}
-
-// openStressSQLite opens a file-backed SQLite DB in t.TempDir. We avoid
-// ":memory:" because the seeded dataset (~50 MB) makes memory pressure a
-// confounding variable — we want to measure migration cost, not GC cost.
-func openStressSQLite(t *testing.T) *sql.DB {
- t.Helper()
- path := filepath.Join(t.TempDir(), "migrate-stress.db")
- db, err := sql.Open("sqlite", path)
- if err != nil {
- t.Fatalf("sql.Open: %v", err)
- }
- // Pool of 1: goose and our PRAGMAs both rely on a single connection to
- // see consistent session state.
- db.SetMaxOpenConns(1)
- if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil {
- db.Close()
- t.Fatalf("PRAGMA foreign_keys: %v", err)
- }
- t.Cleanup(func() { db.Close() })
- return db
-}
-
-func applyPragmas(t *testing.T, db *sql.DB, pragmas ...string) {
- t.Helper()
- for _, p := range pragmas {
- if _, err := db.Exec(p); err != nil {
- t.Fatalf("pragma %q: %v", p, err)
- }
- }
-}
-
-// seedStressData inserts fleet_groups, agents, audit_events, and
-// metric_snapshots. We batch inside a single transaction per table because
-// SQLite's autocommit-per-statement is ~100x slower than a single tx.
-func seedStressData(t *testing.T, db *sql.DB) {
- t.Helper()
- now := time.Now().Unix()
-
- // fleet_groups — referenced by agents.fleet_group_id FK.
- seedBulk(t, db, "fleet_groups",
- []string{"id", "name", "created_at_unix"},
- stressGroups,
- func(i int, a []any) {
- a[0] = fmt.Sprintf("fg-%06d", i)
- a[1] = fmt.Sprintf("group-%d", i)
- a[2] = now
- },
- )
-
- // agents — some with nil fleet_group_id to exercise the nullable FK path
- // that migration 0008 indexes.
- seedBulk(t, db, "agents",
- []string{"id", "node_name", "fleet_group_id", "version", "read_only", "last_seen_at_unix", "created_at_unix"},
- stressAgents,
- func(i int, a []any) {
- a[0] = fmt.Sprintf("agent-%08d", i)
- a[1] = fmt.Sprintf("node-%d", i)
- if i%5 != 0 {
- a[2] = fmt.Sprintf("fg-%06d", i%stressGroups)
- } else {
- a[2] = nil
- }
- a[3] = "1.2.3"
- a[4] = 0
- a[5] = now - int64(i%3600)
- a[6] = now - int64(i%86400)
- },
- )
-
- // audit_events — realistic JSON payload so migration 0011's rename has
- // actual bytes to carry forward.
- seedBulk(t, db, "audit_events",
- []string{"id", "actor_id", "action", "target_id", "created_at_unix", "details_json"},
- stressAudits,
- func(i int, a []any) {
- a[0] = fmt.Sprintf("audit-%09d", i)
- a[1] = fmt.Sprintf("user-%d", i%500)
- a[2] = "client.update"
- a[3] = fmt.Sprintf("target-%d", i)
- a[4] = now - int64(i%86400*30)
- a[5] = `{"ip":"10.0.0.1","ua":"panvex-cli/1.0"}`
- },
- )
-
- // metric_snapshots — the big one. Spread across a 7-day window so the
- // captured_at index from 0008 sees realistic selectivity.
- seedBulk(t, db, "metric_snapshots",
- []string{"id", "agent_id", "instance_id", "captured_at_unix", "values_json"},
- stressMetrics,
- func(i int, a []any) {
- a[0] = fmt.Sprintf("metric-%010d", i)
- a[1] = fmt.Sprintf("agent-%08d", i%stressAgents)
- a[2] = ""
- a[3] = now - int64(i%(7*24*3600))
- a[4] = `{"cpu":0.5,"mem":0.3,"conns":1234}`
- },
- )
-}
-
-// seedBulk bulk-inserts `total` rows into `table` using chunked multi-row
-// INSERTs inside a single transaction. This is 10-50x faster than prepared
-// single-row inserts for the scales we care about.
-func seedBulk(t *testing.T, db *sql.DB, table string, cols []string, total int, rowFn func(i int, args []any)) {
- t.Helper()
- if total == 0 {
- return
- }
- tx, err := db.Begin()
- if err != nil {
- t.Fatalf("begin tx for %s: %v", table, err)
- }
- defer func() {
- if err != nil {
- _ = tx.Rollback()
- }
- }()
-
- placeholders := "(" + strings.Repeat("?,", len(cols)-1) + "?)"
- head := fmt.Sprintf("INSERT INTO %s (%s) VALUES ", table, strings.Join(cols, ","))
-
- for start := 0; start < total; start += stressChunk {
- end := start + stressChunk
- if end > total {
- end = total
- }
- n := end - start
- var sb strings.Builder
- sb.Grow(len(head) + n*(len(placeholders)+1))
- sb.WriteString(head)
- for i := 0; i < n; i++ {
- if i > 0 {
- sb.WriteByte(',')
- }
- sb.WriteString(placeholders)
- }
-
- args := make([]any, 0, n*len(cols))
- rowBuf := make([]any, len(cols))
- for i := 0; i < n; i++ {
- rowFn(start+i, rowBuf)
- args = append(args, rowBuf...)
- }
- if _, err = tx.Exec(sb.String(), args...); err != nil {
- t.Fatalf("insert %s [%d:%d]: %v", table, start, end, err)
- }
- }
- if err = tx.Commit(); err != nil {
- t.Fatalf("commit %s: %v", table, err)
- }
-}
-
-// countTables returns a map of table name -> COUNT(*). Used to diff row
-// counts across the migration boundary.
-func countTables(t *testing.T, db *sql.DB, tables ...string) map[string]int64 {
- t.Helper()
- out := make(map[string]int64, len(tables))
- for _, tbl := range tables {
- var n int64
- if err := db.QueryRow(fmt.Sprintf("SELECT COUNT(*) FROM %s", tbl)).Scan(&n); err != nil {
- t.Fatalf("count %s: %v", tbl, err)
- }
- out[tbl] = n
- }
- return out
-}
diff --git a/internal/controlplane/storage/sqlite/migrate_test.go b/internal/controlplane/storage/sqlite/migrate_test.go
index 8c5da64c..2eff6ba1 100644
--- a/internal/controlplane/storage/sqlite/migrate_test.go
+++ b/internal/controlplane/storage/sqlite/migrate_test.go
@@ -29,22 +29,21 @@ func TestMigrateCreatesGooseVersionTable(t *testing.T) {
t.Fatalf("goose_db_version table not found: %v", err)
}
- // Count applied versions — we expect at least the 8 embedded migrations
- // (0001..0008). The ">=" floor is there so future migrations don't
- // force a brittle equality assertion.
+ // Exactly one embedded migration after the P9 squash: 0001_init.sql.
+ // Pre-squash DBs carry versions 1..58, but this test always starts
+ // from an empty file, so the ledger must contain exactly version 1.
var count int
if err := db.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM goose_db_version WHERE is_applied = 1 AND version_id > 0`).Scan(&count); err != nil {
t.Fatalf("count applied versions: %v", err)
}
- if count < 8 {
- t.Fatalf("expected >= 8 applied goose versions, got %d", count)
+ if count != 1 {
+ t.Fatalf("expected exactly 1 applied goose version (0001_init), got %d", count)
}
}
-// TestMigrateCreatesMissingFKIndexes verifies that migration 0008 creates the
-// four FK/status indexes required by remediation task P2-DB-02. Without these,
-// audit finding DF-22 (missing indexes on high-selectivity foreign keys) stays
-// open — the retention worker and filter APIs would degrade to full scans.
+// TestMigrateCreatesMissingFKIndexes verifies that the squashed 0001_init
+// carries the four FK/status indexes required by remediation task P2-DB-02
+// (historically added by migration 0008; audit finding DF-22).
func TestMigrateCreatesMissingFKIndexes(t *testing.T) {
db := openEmptySQLite(t)
@@ -123,13 +122,12 @@ func TestMigrateCreatesCoreTables(t *testing.T) {
}
}
-// TestMigrateRenamesJSONColumns verifies that migration 0011 renames the
-// SQLite JSON-blob columns to match the PostgreSQL schema (P2-DB-05 / DF-25).
-// Before 0011, SQLite used audit_events.details_json and
-// metric_snapshots.values_json; after 0011, the names are `details` and
-// `values` respectively. Keeping them aligned across backends lets the Store
-// methods share SQL and removes a whole class of copy-paste drift bugs.
-func TestMigrateRenamesJSONColumns(t *testing.T) {
+// TestSchemaUsesCanonicalJSONColumnNames pins the canonical (PostgreSQL-
+// aligned) JSON column names in the SQLite schema: audit_events.details and
+// metric_snapshots."values" (P2-DB-05 / DF-25, historically migration 0011).
+// If a schema edit reintroduces the *_json names, the Store SQL shared
+// between backends silently diverges again — this is the parity canary.
+func TestSchemaUsesCanonicalJSONColumnNames(t *testing.T) {
db := openEmptySQLite(t)
if err := Migrate(db); err != nil {
diff --git a/internal/controlplane/storage/storagetest/store_contract.go b/internal/controlplane/storage/storagetest/store_contract.go
index 1748be5a..650d6d86 100644
--- a/internal/controlplane/storage/storagetest/store_contract.go
+++ b/internal/controlplane/storage/storagetest/store_contract.go
@@ -15,7 +15,7 @@ type OpenStore func(t *testing.T) storage.MigrationStore
// testFleetGroupID is a deterministic UUIDv4 used as the fleet-group
// primary key inside contract-test fixtures. Postgres stores ids in a
-// UUID column since migration 0014, so every PutFleetGroup call must
+// UUID column on PostgreSQL (TEXT on SQLite), so every PutFleetGroup call must
// pass a real UUID. We pick a fixed value so assertions that mention
// the id stay readable.
const testFleetGroupID = "00000000-0000-4000-a000-000000000001"
diff --git a/internal/controlplane/storage/storagetest/store_contract_json_validation.go b/internal/controlplane/storage/storagetest/store_contract_json_validation.go
index 97fd26d2..3902c119 100644
--- a/internal/controlplane/storage/storagetest/store_contract_json_validation.go
+++ b/internal/controlplane/storage/storagetest/store_contract_json_validation.go
@@ -62,7 +62,7 @@ func RunJSONValidationContract(t *testing.T, open OpenStore) {
ctx := context.Background()
err := store.CreateIntegrationProvider(ctx, storage.IntegrationProviderRecord{
- // PostgreSQL's id column is UUID (migration 0014) — SQLite
+ // PostgreSQL's id column is UUID — SQLite
// keeps id as TEXT, but the shared contract must use a value
// valid on both backends.
ID: "00000000-0000-4000-a000-000000000101",
diff --git a/internal/dbsqlc/fleet_groups.sql.go b/internal/dbsqlc/fleet_groups.sql.go
index 7f103ce9..7b241e14 100644
--- a/internal/dbsqlc/fleet_groups.sql.go
+++ b/internal/dbsqlc/fleet_groups.sql.go
@@ -68,7 +68,7 @@ type GetFleetGroupRow struct {
// R-Q-03: fleet_groups — operator-managed grouping of agents +
// enrollment-tokens + client-assignments. Note: id is UUID on
-// postgres (since migration 0014).
+// postgres (UUID id column).
func (q *Queries) GetFleetGroup(ctx context.Context, id uuid.UUID) (GetFleetGroupRow, error) {
row := q.db.QueryRowContext(ctx, getFleetGroup, id)
var i GetFleetGroupRow