Add a dedicated service account column to AI flow triggers
What does this MR do and why?
Adds ai_flow_triggers.autonomous_service_account_id, so an autonomous trigger can carry its own service account.
A catalog flow shares one service account across all its triggers, and composite_identity_enforced is a per-account flag. A scheduled run has no human to pair with, so it needs the flag off, which would break every interactive trigger on the same flow. Today a flow can have scheduled triggers or interactive ones, never both. A per-trigger account removes that.
This MR is the schema and model only. Provisioning and the services that set the column are in !253659; the rest of the stack is listed below.
Related: #594180
Why a new column instead of reusing user_id
Reusing user_id means dropping check_ai_flow_triggers_user_consumer_mutually_exclusive and restoring it on rollback. Restoring a validated constraint needs the offending rows cleaned up first, and a migration runs either DDL or DML but never both, so it took a second migration that could only ever be rolled back as a pair.
A dedicated column removes all of that. The constraint is untouched, and rollback is a plain remove_column.
Stack
- This MR — column and model
- !253659 — provisioning at the default role, and the entitlement policy
- !253667 — choosing the role, and the ceiling
- !253636 —
serviceAccountAccessLevelGraphQL argument
Database
Changed query
None yet: nothing reads the column until !253659. The index is added here because it belongs with the column, and because the loose foreign key on users requires one. Plans below come from !253659's lookup, measured on this schema.
No production data exists for this column, so instead of Database Lab these come from a local database seeded with 200,000 ai_flow_triggers rows, 500 of them autonomous with one dedicated account each.
Account that backs an autonomous trigger (id 1000001):
SELECT 1 AS one FROM ai_flow_triggers WHERE ai_flow_triggers.autonomous_service_account_id = 1000001 LIMIT 1Limit (cost=0.27..2.29 rows=1 width=4) (actual time=0.007..0.007 rows=1 loops=1)
Buffers: shared hit=6
-> Index Only Scan using index_ai_flow_triggers_on_autonomous_service_account_id on ai_flow_triggers (cost=0.27..2.29 rows=1 width=4) (actual time=0.007..0.007 rows=1 loops=1)
Index Cond: (autonomous_service_account_id = 1000001)
Heap Fetches: 1
Buffers: shared hit=6
Planning:
Buffers: shared hit=5
Planning Time: 0.025 ms
Execution Time: 0.011 msAccount that backs none (the common case for the policy check):
SELECT 1 AS one FROM ai_flow_triggers WHERE ai_flow_triggers.autonomous_service_account_id = 999999999 LIMIT 1Limit (cost=0.27..2.29 rows=1 width=4) (actual time=0.003..0.003 rows=0 loops=1)
Buffers: shared hit=2
-> Index Only Scan using index_ai_flow_triggers_on_autonomous_service_account_id on ai_flow_triggers (cost=0.27..2.29 rows=1 width=4) (actual time=0.003..0.003 rows=0 loops=1)
Index Cond: (autonomous_service_account_id = 999999999)
Heap Fetches: 0
Buffers: shared hit=2
Planning Time: 0.009 ms
Execution Time: 0.005 msIndex size: 32 kB over 200000 rows
The index is partial. Only autonomous triggers set the column, so 199,500 of the 200,000 rows are NULL, and WHERE autonomous_service_account_id IS NOT NULL takes the index from 2560 kB to 32 kB. A lookup by account implies IS NOT NULL, so the planner still uses it. The loose foreign key cleanup query is WHERE autonomous_service_account_id IN (...), also covered.
Migration output
== 20260904090000 AddAutonomousServiceAccountIdToAiFlowTriggers: migrating ====
-- add_column(:ai_flow_triggers, :autonomous_service_account_id, :bigint)
-> 0.0116s
== 20260904090000 AddAutonomousServiceAccountIdToAiFlowTriggers: migrated (0.0159s)
== 20260904090001 AddIndexOnAiFlowTriggersAutonomousServiceAccountId: migrating
-- transaction_open?(nil)
-> 0.0000s
-- view_exists?(:postgres_partitions)
-> 0.0104s
-- index_exists?(:ai_flow_triggers, :autonomous_service_account_id, {:name=>"index_ai_flow_triggers_on_autonomous_service_account_id", :where=>"autonomous_service_account_id IS NOT NULL", :algorithm=>:concurrently})
-> 0.0016s
-- execute("SET statement_timeout TO 0")
-> 0.0002s
-- add_index(:ai_flow_triggers, :autonomous_service_account_id, {:name=>"index_ai_flow_triggers_on_autonomous_service_account_id", :where=>"autonomous_service_account_id IS NOT NULL", :algorithm=>:concurrently})
-> 0.0033s
-- execute("RESET statement_timeout")
-> 0.0002s
== 20260904090001 AddIndexOnAiFlowTriggersAutonomousServiceAccountId: migrated (0.0268s)
### down
== 20260904090001 AddIndexOnAiFlowTriggersAutonomousServiceAccountId: reverting
-- transaction_open?(nil)
-> 0.0000s
-- view_exists?(:postgres_partitions)
-> 0.0101s
-- index_name_exists?(:ai_flow_triggers, "index_ai_flow_triggers_on_autonomous_service_account_id")
-> 0.0006s
-- execute("SET statement_timeout TO 0")
-> 0.0002s
-- remove_index(:ai_flow_triggers, {:algorithm=>:concurrently, :name=>"index_ai_flow_triggers_on_autonomous_service_account_id"})
-> 0.0012s
-- execute("RESET statement_timeout")
-> 0.0002s
== 20260904090001 AddIndexOnAiFlowTriggersAutonomousServiceAccountId: reverted (0.0244s)
== 20260904090000 AddAutonomousServiceAccountIdToAiFlowTriggers: reverting ====
-- remove_column(:ai_flow_triggers, :autonomous_service_account_id, :bigint)
-> 0.0280s
== 20260904090000 AddAutonomousServiceAccountIdToAiFlowTriggers: reverted (0.0339s)Testing
Model spec covers the new association, the absence validation for non-autonomous triggers, service_account resolution preferring the trigger's own account, and loose foreign key cleanup on the new column.
380 examples pass across the model spec and ee/spec/services/ai/flow_triggers/.