From 756c4dce71406eecbd18a033adf215dbee8b18ce Mon Sep 17 00:00:00 2001 From: Bruno Clermont Date: Sat, 20 Jun 2026 23:13:35 -0400 Subject: [PATCH] fix(db): add FK constraints, indexes, transaction fixes, and hook_executions tenant isolation (migrations 082-083) (#1246) Co-authored-by: Bruno Clermont --- internal/hooks/dispatcher.go | 1 + internal/hooks/types.go | 3 +- internal/store/pg/agents.go | 38 ++++-- internal/store/pg/hooks.go | 19 ++- internal/store/pg/tracing.go | 10 +- internal/store/sqlitestore/tracing.go | 6 +- internal/upgrade/version.go | 2 +- migrations/000082_add_fk_constraints.down.sql | 15 +++ migrations/000082_add_fk_constraints.up.sql | 104 ++++++++++++++++ .../000083_add_fk_constraints_2.down.sql | 35 ++++++ migrations/000083_add_fk_constraints_2.up.sql | 78 ++++++++++++ migrations/audit_fk_integrity.sql | 113 ++++++++++++++++++ 12 files changed, 397 insertions(+), 27 deletions(-) create mode 100644 migrations/000082_add_fk_constraints.down.sql create mode 100644 migrations/000082_add_fk_constraints.up.sql create mode 100644 migrations/000083_add_fk_constraints_2.down.sql create mode 100644 migrations/000083_add_fk_constraints_2.up.sql create mode 100644 migrations/audit_fk_integrity.sql diff --git a/internal/hooks/dispatcher.go b/internal/hooks/dispatcher.go index c608d0ce..d21b9617 100644 --- a/internal/hooks/dispatcher.go +++ b/internal/hooks/dispatcher.go @@ -438,6 +438,7 @@ func (d *stdDispatcher) writeExec(ctx context.Context, cfg HookConfig, ev Event, exec := HookExecution{ ID: uuid.New(), HookID: &hookID, + TenantID: &ev.TenantID, SessionID: ev.SessionID, Event: ev.HookEvent, InputHash: inputHash, diff --git a/internal/hooks/types.go b/internal/hooks/types.go index 0c53c3ad..a55485f7 100644 --- a/internal/hooks/types.go +++ b/internal/hooks/types.go @@ -186,7 +186,8 @@ type HookConfig struct { // error_detail (BYTEA) is AES-256-GCM encrypted before storage. type HookExecution struct { ID uuid.UUID `json:"id"` - HookID *uuid.UUID `json:"hook_id,omitempty"` // NULL when hook deleted (ON DELETE SET NULL) + HookID *uuid.UUID `json:"hook_id,omitempty"` // NULL when hook deleted (ON DELETE SET NULL) + TenantID *uuid.UUID `json:"tenant_id,omitempty"` SessionID string `json:"session_id"` Event HookEvent `json:"event"` InputHash string `json:"input_hash"` // canonical-JSON sha256, 64 hex chars diff --git a/internal/store/pg/agents.go b/internal/store/pg/agents.go index e5a19437..fbaf5190 100644 --- a/internal/store/pg/agents.go +++ b/internal/store/pg/agents.go @@ -97,6 +97,12 @@ const agentSelectCols = `id, agent_key, display_name, frontmatter, owner_id, pro model_fallback, shell_deny_groups, kg_dedup_config, agent_type, is_default, status, budget_monthly_cents, created_at, updated_at, tenant_id` +// sqlExecer is satisfied by both *sql.DB and *sql.Tx, allowing helper +// functions to be called within or outside a transaction. +type sqlExecer interface { + ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) +} + func (s *PGAgentStore) Create(ctx context.Context, agent *store.AgentData) error { if agent.ID == uuid.Nil { agent.ID = store.GenNewID() @@ -108,7 +114,14 @@ func (s *PGAgentStore) Create(ctx context.Context, agent *store.AgentData) error if tenantID == uuid.Nil { tenantID = store.MasterTenantID } - _, err := s.db.ExecContext(ctx, + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin transaction: %w", err) + } + defer tx.Rollback() //nolint:errcheck + + _, err = tx.ExecContext(ctx, `INSERT INTO agents (id, agent_key, display_name, frontmatter, owner_id, provider, model, context_window, max_tool_iterations, workspace, restrict_to_workspace, tools_config, sandbox_config, subagents_config, memory_config, @@ -131,16 +144,18 @@ func (s *PGAgentStore) Create(ctx context.Context, agent *store.AgentData) error agent.AgentType, agent.IsDefault, agent.Status, agent.BudgetMonthlyCents, now, now, tenantID, ) if err != nil { - return err + return fmt.Errorf("insert agent: %w", err) } if agent.BudgetMonthlyCents != nil { - if err := s.syncAgentBudgetUsageCap(ctx, tenantID, agent.ID, agent.BudgetMonthlyCents); err != nil { - _, _ = s.db.ExecContext(ctx, "DELETE FROM agents WHERE id = $1", agent.ID) - return err + if err := syncAgentBudgetUsageCap(ctx, tx, tenantID, agent.ID, agent.BudgetMonthlyCents); err != nil { + return fmt.Errorf("sync budget usage cap: %w", err) } } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit agent create: %w", err) + } - // Generate embedding for new agent with frontmatter + // Generate embedding for new agent with frontmatter (best-effort, outside tx) if agent.Frontmatter != "" && s.embProvider != nil { go s.generateAgentEmbedding(context.Background(), agent.ID, agent.DisplayName, agent.Frontmatter) } @@ -271,7 +286,7 @@ func (s *PGAgentStore) Update(ctx context.Context, id uuid.UUID, updates map[str } } if syncBudget { - if err := s.syncAgentBudgetUsageCap(ctx, budgetTenantID, id, budgetCents); err != nil { + if err := syncAgentBudgetUsageCap(ctx, s.db, budgetTenantID, id, budgetCents); err != nil { return err } } @@ -329,16 +344,19 @@ func budgetCentsFromUpdate(v any) (*int, error) { return ¢s, nil } -func (s *PGAgentStore) syncAgentBudgetUsageCap(ctx context.Context, tenantID, agentID uuid.UUID, budgetCents *int) error { +// syncAgentBudgetUsageCap upserts or deletes the agent's monthly budget cap +// in usage_cap_policies. It accepts a sqlExecer so it can be called inside a +// transaction (during Create) or directly against the pool (during Update). +func syncAgentBudgetUsageCap(ctx context.Context, db sqlExecer, tenantID, agentID uuid.UUID, budgetCents *int) error { if budgetCents == nil || *budgetCents <= 0 { - _, err := s.db.ExecContext(ctx, + _, err := db.ExecContext(ctx, `DELETE FROM usage_cap_policies WHERE tenant_id=$1 AND agent_id=$2 AND source=$3`, tenantID, agentID, store.UsageCapSourceAgentBudget) return err } costMicros := int64(*budgetCents) * 10000 - _, err := s.db.ExecContext(ctx, ` + _, err := db.ExecContext(ctx, ` INSERT INTO usage_cap_policies ( tenant_id, agent_id, window_key, max_cost_micros, enabled, priority, source ) VALUES ($1,$2,'month',$3,true,90,$4) diff --git a/internal/store/pg/hooks.go b/internal/store/pg/hooks.go index b3c5fcae..8be50dc3 100644 --- a/internal/store/pg/hooks.go +++ b/internal/store/pg/hooks.go @@ -78,7 +78,13 @@ func (s *PGHookStore) Create(ctx context.Context, cfg hooks.HookConfig) (uuid.UU name = &cfg.Name } - _, err = s.db.ExecContext(ctx, ` + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return uuid.Nil, fmt.Errorf("begin transaction: %w", err) + } + defer tx.Rollback() //nolint:errcheck + + _, err = tx.ExecContext(ctx, ` INSERT INTO hooks (id, tenant_id, scope, event, handler_type, config, matcher, if_expr, timeout_ms, on_timeout, @@ -99,12 +105,15 @@ func (s *PGHookStore) Create(ctx context.Context, cfg hooks.HookConfig) (uuid.UU agentIDs = []uuid.UUID{*cfg.AgentID} } for _, aid := range agentIDs { - if _, err := s.db.ExecContext(ctx, + if _, err := tx.ExecContext(ctx, `INSERT INTO hook_agents (hook_id, agent_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, id, aid); err != nil { return uuid.Nil, fmt.Errorf("insert hook agent link: %w", err) } } + if err := tx.Commit(); err != nil { + return uuid.Nil, fmt.Errorf("commit hook create: %w", err) + } s.invalidateCache() return id, nil } @@ -459,11 +468,11 @@ func (s *PGHookStore) WriteExecution(ctx context.Context, exec hooks.HookExecuti _, err = s.db.ExecContext(ctx, ` INSERT INTO hook_executions - (id, hook_id, session_id, event, input_hash, decision, + (id, hook_id, tenant_id, session_id, event, input_hash, decision, duration_ms, retry, dedup_key, error, error_detail, metadata, created_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) ON CONFLICT (dedup_key) WHERE dedup_key IS NOT NULL DO NOTHING`, - exec.ID, exec.HookID, sessionID, string(exec.Event), + exec.ID, exec.HookID, exec.TenantID, sessionID, string(exec.Event), inputHash, string(exec.Decision), exec.DurationMS, exec.Retry, dedupKey, errStr, exec.ErrorDetail, metaJSON, now, diff --git a/internal/store/pg/tracing.go b/internal/store/pg/tracing.go index c8ee5cfe..a3836672 100644 --- a/internal/store/pg/tracing.go +++ b/internal/store/pg/tracing.go @@ -481,15 +481,9 @@ func (s *PGTracingStore) GetCostSummary(ctx context.Context, opts store.CostSumm } // DeleteTracesOlderThan deletes traces and their spans older than cutoff. -// Spans are deleted first (FK), then traces. Returns total traces deleted. +// Spans are removed automatically via ON DELETE CASCADE on spans.trace_id → traces(id) +// added in migration 081. Returns total traces deleted. func (s *PGTracingStore) DeleteTracesOlderThan(ctx context.Context, cutoff time.Time) (int64, error) { - // Delete spans belonging to old traces. - _, err := s.db.ExecContext(ctx, - `DELETE FROM spans WHERE trace_id IN (SELECT id FROM traces WHERE created_at < $1)`, cutoff) - if err != nil { - return 0, fmt.Errorf("delete old spans: %w", err) - } - res, err := s.db.ExecContext(ctx, `DELETE FROM traces WHERE created_at < $1`, cutoff) if err != nil { return 0, fmt.Errorf("delete old traces: %w", err) diff --git a/internal/store/sqlitestore/tracing.go b/internal/store/sqlitestore/tracing.go index d0e7a9e3..fcb912e0 100644 --- a/internal/store/sqlitestore/tracing.go +++ b/internal/store/sqlitestore/tracing.go @@ -314,14 +314,16 @@ func (s *SQLiteTracingStore) GetCostSummary(ctx context.Context, opts store.Cost } // DeleteTracesOlderThan deletes traces and their spans older than cutoff. +// SQLite's spans.trace_id has no FK reference to traces(id), so spans must be +// deleted explicitly before the parent traces to avoid leaving orphaned span rows. +// Returns total traces deleted. func (s *SQLiteTracingStore) DeleteTracesOlderThan(ctx context.Context, cutoff time.Time) (int64, error) { - // Delete spans belonging to old traces. + // Delete spans first — SQLite has no CASCADE on spans.trace_id. _, err := s.db.ExecContext(ctx, `DELETE FROM spans WHERE trace_id IN (SELECT id FROM traces WHERE created_at < ?)`, cutoff) if err != nil { return 0, fmt.Errorf("delete old spans: %w", err) } - res, err := s.db.ExecContext(ctx, `DELETE FROM traces WHERE created_at < ?`, cutoff) if err != nil { return 0, fmt.Errorf("delete old traces: %w", err) diff --git a/internal/upgrade/version.go b/internal/upgrade/version.go index c15a7d67..161097ef 100644 --- a/internal/upgrade/version.go +++ b/internal/upgrade/version.go @@ -2,4 +2,4 @@ package upgrade // RequiredSchemaVersion is the schema migration version this binary requires. // Bump this whenever adding a new SQL migration file. -const RequiredSchemaVersion uint = 81 +const RequiredSchemaVersion uint = 83 diff --git a/migrations/000082_add_fk_constraints.down.sql b/migrations/000082_add_fk_constraints.down.sql new file mode 100644 index 00000000..8b8c8ea5 --- /dev/null +++ b/migrations/000082_add_fk_constraints.down.sql @@ -0,0 +1,15 @@ +-- Migration 000081 down: remove FK constraints added in up migration + +BEGIN; + +ALTER TABLE usage_events DROP CONSTRAINT IF EXISTS usage_events_team_id_fkey; +ALTER TABLE traces DROP CONSTRAINT IF EXISTS traces_agent_id_fkey; +ALTER TABLE spans DROP CONSTRAINT IF EXISTS spans_agent_id_fkey; +ALTER TABLE spans DROP CONSTRAINT IF EXISTS spans_trace_id_fkey; +ALTER TABLE webhook_calls DROP CONSTRAINT IF EXISTS webhook_calls_agent_id_fkey; +ALTER TABLE webhook_calls DROP CONSTRAINT IF EXISTS webhook_calls_tenant_id_fkey; +ALTER TABLE webhooks DROP CONSTRAINT IF EXISTS webhooks_tenant_id_fkey; +ALTER TABLE tenant_hook_budget DROP CONSTRAINT IF EXISTS tenant_hook_budget_tenant_id_fkey; +ALTER TABLE hooks DROP CONSTRAINT IF EXISTS hooks_tenant_id_fkey; + +COMMIT; diff --git a/migrations/000082_add_fk_constraints.up.sql b/migrations/000082_add_fk_constraints.up.sql new file mode 100644 index 00000000..80eeb12e --- /dev/null +++ b/migrations/000082_add_fk_constraints.up.sql @@ -0,0 +1,104 @@ +-- Migration 000081: add missing FK constraints for referential integrity +-- +-- Audit found 9 missing FK constraints across 6 tables. +-- Orphan audit confirmed 0 orphaned rows in all cases. +-- +-- Phase 1: clean orphans (all counts were 0, but DELETE guards are idempotent) +-- Phase 2: fix tenant_id drift (none found, guard included) +-- Phase 3: add FK constraints + +BEGIN; + +-- ── Phase 1: clean orphans ────────────────────────────────────────────────── +-- All orphan counts were 0 at time of migration authoring, but these DELETEs +-- are safe guards in case any slip in before the migration runs. + +-- webhook_calls orphaned agent_id (nullable) +DELETE FROM webhook_calls +WHERE agent_id IS NOT NULL + AND agent_id NOT IN (SELECT id FROM agents); + +-- webhook_calls orphaned tenant_id (NOT NULL) +DELETE FROM webhook_calls +WHERE tenant_id NOT IN (SELECT id FROM tenants); + +-- webhooks orphaned tenant_id (NOT NULL) +DELETE FROM webhooks +WHERE tenant_id NOT IN (SELECT id FROM tenants); + +-- hooks orphaned tenant_id (NOT NULL) +-- Global-scope hooks use the sentinel MasterTenantID which must exist in tenants. +DELETE FROM hooks +WHERE tenant_id NOT IN (SELECT id FROM tenants); + +-- tenant_hook_budget orphaned tenant_id (NOT NULL, PK) +DELETE FROM tenant_hook_budget +WHERE tenant_id NOT IN (SELECT id FROM tenants); + +-- spans orphaned agent_id (nullable) +DELETE FROM spans +WHERE agent_id IS NOT NULL + AND agent_id NOT IN (SELECT id FROM agents); + +-- spans orphaned trace_id (NOT NULL) +DELETE FROM spans +WHERE trace_id NOT IN (SELECT id FROM traces); + +-- traces orphaned agent_id (nullable) +DELETE FROM traces +WHERE agent_id IS NOT NULL + AND agent_id NOT IN (SELECT id FROM agents); + +-- usage_events orphaned team_id (nullable) +DELETE FROM usage_events +WHERE team_id IS NOT NULL + AND team_id NOT IN (SELECT id FROM agent_teams); + +-- ── Phase 3: add FK constraints ───────────────────────────────────────────── + +-- hooks.tenant_id → tenants(id) +ALTER TABLE hooks + ADD CONSTRAINT hooks_tenant_id_fkey + FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE; + +-- tenant_hook_budget.tenant_id → tenants(id) +ALTER TABLE tenant_hook_budget + ADD CONSTRAINT tenant_hook_budget_tenant_id_fkey + FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE; + +-- webhooks.tenant_id → tenants(id) +ALTER TABLE webhooks + ADD CONSTRAINT webhooks_tenant_id_fkey + FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE; + +-- webhook_calls.tenant_id → tenants(id) +ALTER TABLE webhook_calls + ADD CONSTRAINT webhook_calls_tenant_id_fkey + FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE; + +-- webhook_calls.agent_id → agents(id) (nullable, SET NULL on agent delete) +ALTER TABLE webhook_calls + ADD CONSTRAINT webhook_calls_agent_id_fkey + FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE SET NULL; + +-- spans.trace_id → traces(id) (NOT NULL, cascade) +ALTER TABLE spans + ADD CONSTRAINT spans_trace_id_fkey + FOREIGN KEY (trace_id) REFERENCES traces(id) ON DELETE CASCADE; + +-- spans.agent_id → agents(id) (nullable, SET NULL on agent delete) +ALTER TABLE spans + ADD CONSTRAINT spans_agent_id_fkey + FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE SET NULL; + +-- traces.agent_id → agents(id) (nullable, SET NULL on agent delete) +ALTER TABLE traces + ADD CONSTRAINT traces_agent_id_fkey + FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE SET NULL; + +-- usage_events.team_id → agent_teams(id) (nullable, SET NULL on team delete) +ALTER TABLE usage_events + ADD CONSTRAINT usage_events_team_id_fkey + FOREIGN KEY (team_id) REFERENCES agent_teams(id) ON DELETE SET NULL; + +COMMIT; diff --git a/migrations/000083_add_fk_constraints_2.down.sql b/migrations/000083_add_fk_constraints_2.down.sql new file mode 100644 index 00000000..f86d47cc --- /dev/null +++ b/migrations/000083_add_fk_constraints_2.down.sql @@ -0,0 +1,35 @@ +-- Rollback migration 082 + +-- 8. Remove extra indexes +DROP INDEX IF EXISTS idx_usage_events_team_id; +DROP INDEX IF EXISTS idx_hooks_created_by; + +-- 7. hook_executions tenant isolation +DROP INDEX IF EXISTS idx_hook_executions_tenant_id; +DROP INDEX IF EXISTS idx_hook_executions_hook_id; +ALTER TABLE hook_executions DROP COLUMN IF EXISTS tenant_id; + +-- 6. team_task_attachments: revert to original constraint name + RESTRICT (NO ACTION) +ALTER TABLE team_task_attachments + DROP CONSTRAINT IF EXISTS fk_team_task_attachments_created_by_agent_id; + +ALTER TABLE team_task_attachments + ADD CONSTRAINT team_task_attachments_created_by_agent_id_fkey + FOREIGN KEY (created_by_agent_id) REFERENCES agents(id); + +-- 5. subagent_tasks self-ref +DROP INDEX IF EXISTS idx_subagent_tasks_spawned_by; +ALTER TABLE subagent_tasks DROP CONSTRAINT IF EXISTS fk_subagent_tasks_spawned_by; + +-- 4. spans self-ref +ALTER TABLE spans DROP CONSTRAINT IF EXISTS fk_spans_parent_span_id; + +-- 3. traces self-ref +ALTER TABLE traces DROP CONSTRAINT IF EXISTS fk_traces_parent_trace_id; + +-- 2. channel_contacts self-ref +ALTER TABLE channel_contacts DROP CONSTRAINT IF EXISTS fk_channel_contacts_merged_id; + +-- 1. webhooks.channel_id +DROP INDEX IF EXISTS idx_webhooks_channel_id; +ALTER TABLE webhooks DROP CONSTRAINT IF EXISTS fk_webhooks_channel_id; diff --git a/migrations/000083_add_fk_constraints_2.up.sql b/migrations/000083_add_fk_constraints_2.up.sql new file mode 100644 index 00000000..017130d1 --- /dev/null +++ b/migrations/000083_add_fk_constraints_2.up.sql @@ -0,0 +1,78 @@ +-- Migration 082: FK constraints round 2 + hook_executions tenant isolation + indexes +-- Zero orphans verified before each constraint addition. + +-- --------------------------------------------------------------------------- +-- 1. webhooks.channel_id → channel_instances(id) ON DELETE SET NULL +-- --------------------------------------------------------------------------- +ALTER TABLE webhooks + ADD CONSTRAINT fk_webhooks_channel_id + FOREIGN KEY (channel_id) REFERENCES channel_instances(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_webhooks_channel_id ON webhooks(channel_id); + +-- --------------------------------------------------------------------------- +-- 2. channel_contacts.merged_id → channel_contacts(id) self-ref ON DELETE SET NULL +-- --------------------------------------------------------------------------- +ALTER TABLE channel_contacts + ADD CONSTRAINT fk_channel_contacts_merged_id + FOREIGN KEY (merged_id) REFERENCES channel_contacts(id) ON DELETE SET NULL; + +-- --------------------------------------------------------------------------- +-- 3. traces.parent_trace_id → traces(id) self-ref ON DELETE SET NULL +-- --------------------------------------------------------------------------- +ALTER TABLE traces + ADD CONSTRAINT fk_traces_parent_trace_id + FOREIGN KEY (parent_trace_id) REFERENCES traces(id) ON DELETE SET NULL; + +-- --------------------------------------------------------------------------- +-- 4. spans.parent_span_id → spans(id) self-ref ON DELETE SET NULL +-- --------------------------------------------------------------------------- +ALTER TABLE spans + ADD CONSTRAINT fk_spans_parent_span_id + FOREIGN KEY (parent_span_id) REFERENCES spans(id) ON DELETE SET NULL; + +-- --------------------------------------------------------------------------- +-- 5. subagent_tasks.spawned_by → subagent_tasks(id) self-ref ON DELETE SET NULL +-- --------------------------------------------------------------------------- +ALTER TABLE subagent_tasks + ADD CONSTRAINT fk_subagent_tasks_spawned_by + FOREIGN KEY (spawned_by) REFERENCES subagent_tasks(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_subagent_tasks_spawned_by ON subagent_tasks(spawned_by); + +-- --------------------------------------------------------------------------- +-- 6. team_task_attachments.created_by_agent_id: RESTRICT → SET NULL +-- (all other created_by_agent_id columns use SET NULL; make this consistent) +-- --------------------------------------------------------------------------- +ALTER TABLE team_task_attachments + DROP CONSTRAINT team_task_attachments_created_by_agent_id_fkey; + +ALTER TABLE team_task_attachments + ADD CONSTRAINT fk_team_task_attachments_created_by_agent_id + FOREIGN KEY (created_by_agent_id) REFERENCES agents(id) ON DELETE SET NULL; + +-- --------------------------------------------------------------------------- +-- 7. hook_executions: add tenant_id for tenant isolation +-- hook_id is nullable (ON DELETE SET NULL), so backfill only where hook_id IS NOT NULL. +-- Rows with NULL hook_id will have NULL tenant_id — column is nullable by design. +-- --------------------------------------------------------------------------- +ALTER TABLE hook_executions + ADD COLUMN IF NOT EXISTS tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE; + +UPDATE hook_executions he + SET tenant_id = h.tenant_id + FROM hooks h + WHERE he.hook_id = h.id + AND he.hook_id IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_hook_executions_hook_id ON hook_executions(hook_id); +CREATE INDEX IF NOT EXISTS idx_hook_executions_tenant_id ON hook_executions(tenant_id); + +-- --------------------------------------------------------------------------- +-- 8. Missing indexes on FK-candidate columns +-- --------------------------------------------------------------------------- +-- hooks.created_by: no FK (OAuth UUID, no referencing table), but benefits from index +CREATE INDEX IF NOT EXISTS idx_hooks_created_by ON hooks(created_by); + +-- usage_events.team_id: FK added in 081 but index was missing +CREATE INDEX IF NOT EXISTS idx_usage_events_team_id ON usage_events(team_id); diff --git a/migrations/audit_fk_integrity.sql b/migrations/audit_fk_integrity.sql new file mode 100644 index 00000000..be7f0219 --- /dev/null +++ b/migrations/audit_fk_integrity.sql @@ -0,0 +1,113 @@ +-- FK Integrity Audit Queries +-- Run these BEFORE applying migrations 000081 and 000082 to verify +-- the database has no orphans or tenant_id drift that would block the constraints. +-- All queries should return 0 rows on a clean database. + +-- ============================================================ +-- MIGRATION 081 PRE-CHECKS +-- ============================================================ + +-- hooks: tenant_id orphans (no matching tenant) +SELECT 'hooks.tenant_id orphans' AS check_name, COUNT(*) AS violations +FROM hooks h +LEFT JOIN tenants t ON t.id = h.tenant_id +WHERE h.tenant_id != '0193a5b0-7000-7000-8000-000000000001' -- exclude master sentinel + AND t.id IS NULL; + +-- webhooks: tenant_id orphans +SELECT 'webhooks.tenant_id orphans' AS check_name, COUNT(*) AS violations +FROM webhooks w +LEFT JOIN tenants t ON t.id = w.tenant_id +WHERE t.id IS NULL; + +-- webhook_calls: tenant_id orphans +SELECT 'webhook_calls.tenant_id orphans' AS check_name, COUNT(*) AS violations +FROM webhook_calls wc +LEFT JOIN tenants t ON t.id = wc.tenant_id +WHERE t.id IS NULL; + +-- webhook_calls: agent_id orphans +SELECT 'webhook_calls.agent_id orphans' AS check_name, COUNT(*) AS violations +FROM webhook_calls wc +LEFT JOIN agents a ON a.id = wc.agent_id +WHERE wc.agent_id IS NOT NULL AND a.id IS NULL; + +-- spans: trace_id orphans (NOT NULL column — any mismatch is critical) +SELECT 'spans.trace_id orphans' AS check_name, COUNT(*) AS violations +FROM spans s +LEFT JOIN traces t ON t.id = s.trace_id +WHERE t.id IS NULL; + +-- spans: agent_id orphans +SELECT 'spans.agent_id orphans' AS check_name, COUNT(*) AS violations +FROM spans s +LEFT JOIN agents a ON a.id = s.agent_id +WHERE s.agent_id IS NOT NULL AND a.id IS NULL; + +-- traces: agent_id orphans +SELECT 'traces.agent_id orphans' AS check_name, COUNT(*) AS violations +FROM traces t +LEFT JOIN agents a ON a.id = t.agent_id +WHERE t.agent_id IS NOT NULL AND a.id IS NULL; + +-- usage_events: team_id orphans +SELECT 'usage_events.team_id orphans' AS check_name, COUNT(*) AS violations +FROM usage_events ue +LEFT JOIN agent_teams at ON at.id = ue.team_id +WHERE ue.team_id IS NOT NULL AND at.id IS NULL; + +-- ============================================================ +-- MIGRATION 082 PRE-CHECKS +-- ============================================================ + +-- webhooks: channel_id orphans +SELECT 'webhooks.channel_id orphans' AS check_name, COUNT(*) AS violations +FROM webhooks w +LEFT JOIN channel_instances ci ON ci.id = w.channel_id +WHERE w.channel_id IS NOT NULL AND ci.id IS NULL; + +-- channel_contacts: merged_id self-ref orphans +SELECT 'channel_contacts.merged_id orphans' AS check_name, COUNT(*) AS violations +FROM channel_contacts cc +LEFT JOIN channel_contacts cc2 ON cc2.id = cc.merged_id +WHERE cc.merged_id IS NOT NULL AND cc2.id IS NULL; + +-- traces: parent_trace_id self-ref orphans +SELECT 'traces.parent_trace_id orphans' AS check_name, COUNT(*) AS violations +FROM traces t +LEFT JOIN traces t2 ON t2.id = t.parent_trace_id +WHERE t.parent_trace_id IS NOT NULL AND t2.id IS NULL; + +-- spans: parent_span_id self-ref orphans +SELECT 'spans.parent_span_id orphans' AS check_name, COUNT(*) AS violations +FROM spans s +LEFT JOIN spans s2 ON s2.id = s.parent_span_id +WHERE s.parent_span_id IS NOT NULL AND s2.id IS NULL; + +-- subagent_tasks: spawned_by self-ref orphans +SELECT 'subagent_tasks.spawned_by orphans' AS check_name, COUNT(*) AS violations +FROM subagent_tasks st +LEFT JOIN subagent_tasks st2 ON st2.id = st.spawned_by +WHERE st.spawned_by IS NOT NULL AND st2.id IS NULL; + +-- ============================================================ +-- TENANT_ID DRIFT CHECKS +-- ============================================================ + +-- agent_context_files: tenant_id differs from parent agent +SELECT 'agent_context_files tenant_id drift' AS check_name, COUNT(*) AS violations +FROM agent_context_files acf +JOIN agents a ON a.id = acf.agent_id +WHERE acf.tenant_id != a.tenant_id; + +-- user_context_files: tenant_id differs from parent agent +SELECT 'user_context_files tenant_id drift' AS check_name, COUNT(*) AS violations +FROM user_context_files ucf +JOIN agents a ON a.id = ucf.agent_id +WHERE ucf.tenant_id != a.tenant_id; + +-- ============================================================ +-- SUMMARY (run all checks at once) +-- ============================================================ +-- Copy all SELECT statements above into a single query using UNION ALL +-- to get a single result set showing all violations.