fix(db): add FK constraints, indexes, transaction fixes, and hook_executions tenant isolation (migrations 082-083) (#1246)

Co-authored-by: Bruno Clermont <bruno.clermont@gmail.com>
This commit is contained in:
Bruno ClermontandBruno Clermont authored and GitHub committed 2026-06-21 10:13:35 +07:00
1 parent c31dd21934
commit 756c4dce71
12 files changed
+397 -27

No files matched your search

@@ -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;
+104
View File
@@ -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;
@@ -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;
@@ -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);
+113
View File
@@ -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.