Add ai_governance_sessions table

What does this MR do and why?

Adds ai_governance_sessions, one row per AI session. It covers GitLab Duo sessions (Chat and agent flows) and external agent sessions such as Claude Code, MCP and glab.

Today an external agent session can only be stored by creating a dummy duo_workflows_workflows row, because duo_workflow_session_artifacts.workflow_id is NOT NULL with a foreign key. That pollutes the workflows table. ai_governance_sessions removes the need for it.

Nothing consumes the table yet.

Design points:

  • namespace_id is NOT NULL and is the sharding key. Project-scoped rows store the project namespace id, so a group hierarchy lookup is a single namespace_id IN (...). This copies duo_workflow_session_artifacts rather than duo_workflows_workflows, which uses a XOR check constraint and so needs a join for group queries.
  • namespace_id has a loose foreign key (config/gitlab_loose_foreign_keys.yml, on_delete: async_delete): when a namespace is deleted, its sessions are deleted asynchronously, since the session data belongs to the namespace. project_id, user_id and workflow_id have no foreign key at all, so sessions are kept and these ids can dangle when the project, user or workflow is deleted — audit data should outlive the entity it describes. Only project_id, user_id and workflow_id are listed in spec/db/schema_spec.rb's ignored_fk_columns_map; sharding_key_spec.rb doesn't need a change, since the loose FK on namespace_id already satisfies its check.
  • db/docs/data_retention/ai_governance_sessions.yml declares indefinite_retention: there's no time-based purge, and namespace deletion is the only path that removes sessions.
  • namespace_id and user_id are still validated with presence: true at the model level, matching their NOT NULL columns, even though the associations are all optional: true so a session stays readable and writable once its target is gone.
  • A session is either Duo-sourced (workflow_id, a back-pointer to a real Ai::DuoWorkflows::Workflow) or externally-sourced (external_xid, an external agent's own identifier). ExactlyOnePresentValidator enforces exactly one of the two, never both, never neither.
  • The keyset index is named explicitly. The generated name would be index_ai_governance_sessions_on_namespace_id_and_session_started_at_and_id, 74 characters, past the 63-character Postgres identifier limit.
  • Includes a dev fixture (ee/db/fixtures/development/99_ai_governance_sessions.rb) seeding one Duo-sourced and one external-agent row, so db:migrate:multi-version-upgrade has a row to exercise for this new table.

References

Database

Click to expand Migration command output
### up

bundle exec rake db:migrate:up_all VERSION=20260918110508
main: == [advisory_lock_connection] object_id: 164860, pg_backend_pid: 36506
main: == 20260918110508 CreateAiGovernanceSessions: migrating =======================
main: -- create_table(:ai_governance_sessions, {:if_not_exists=>true})
main: -- quote_column_name(:external_xid)
main:    -> 0.0000s
main: -- quote_column_name(:agent_type)
main:    -> 0.0000s
main: -- quote_column_name(:model_used)
main:    -> 0.0000s
main: -- quote_column_name(:flow_type)
main:    -> 0.0000s
main:    -> 0.0911s
main: == 20260918110508 CreateAiGovernanceSessions: migrated (0.0975s) ==============

main: == [advisory_lock_connection] object_id: 164860, pg_backend_pid: 36506
ci: == [advisory_lock_connection] object_id: 164860, pg_backend_pid: 36507
ci: == 20260918110508 CreateAiGovernanceSessions: migrating =======================
ci: -- create_table(:ai_governance_sessions, {:if_not_exists=>true})
ci: -- quote_column_name(:external_xid)
ci:    -> 0.0000s
ci: -- quote_column_name(:agent_type)
ci:    -> 0.0000s
ci: -- quote_column_name(:model_used)
ci:    -> 0.0000s
ci: -- quote_column_name(:flow_type)
ci:    -> 0.0000s
ci:    -> 0.0230s
I, [2026-09-21T10:54:42.130732 #35637]  INFO -- : Database: 'ci', Table: 'ai_governance_sessions': Lock Writes
ci: == 20260918110508 CreateAiGovernanceSessions: migrated (0.0372s) ==============

ci: == [advisory_lock_connection] object_id: 164860, pg_backend_pid: 36507
sec: == [advisory_lock_connection] object_id: 164860, pg_backend_pid: 36509
sec: == 20260918110508 CreateAiGovernanceSessions: migrating =======================
sec: -- create_table(:ai_governance_sessions, {:if_not_exists=>true})
sec: -- quote_column_name(:external_xid)
sec:    -> 0.0000s
sec: -- quote_column_name(:agent_type)
sec:    -> 0.0000s
sec: -- quote_column_name(:model_used)
sec:    -> 0.0000s
sec: -- quote_column_name(:flow_type)
sec:    -> 0.0000s
sec:    -> 0.0229s
I, [2026-09-21T10:54:42.303934 #35637]  INFO -- : Database: 'sec', Table: 'ai_governance_sessions': Lock Writes
sec: == 20260918110508 CreateAiGovernanceSessions: migrated (0.0366s) ==============

sec: == [advisory_lock_connection] object_id: 164860, pg_backend_pid: 36509

### down

bundle exec rake db:migrate:down_all VERSION=20260918110508
main: == [advisory_lock_connection] object_id: 164840, pg_backend_pid: 34643
main: == 20260918110508 CreateAiGovernanceSessions: reverting =======================
main: -- drop_table(:ai_governance_sessions)
main:    -> 0.0417s
main: == 20260918110508 CreateAiGovernanceSessions: reverted (0.0478s) ==============

main: == [advisory_lock_connection] object_id: 164840, pg_backend_pid: 34643
ci: == [advisory_lock_connection] object_id: 164840, pg_backend_pid: 34644
ci: == 20260918110508 CreateAiGovernanceSessions: reverting =======================
ci: -- drop_table(:ai_governance_sessions)
ci:    -> 0.0035s
ci: == 20260918110508 CreateAiGovernanceSessions: reverted (0.0129s) ==============

ci: == [advisory_lock_connection] object_id: 164840, pg_backend_pid: 34644
sec: == [advisory_lock_connection] object_id: 164840, pg_backend_pid: 34646
sec: == 20260918110508 CreateAiGovernanceSessions: reverting =======================
sec: -- drop_table(:ai_governance_sessions)
sec:    -> 0.0037s
sec: == 20260918110508 CreateAiGovernanceSessions: reverted (0.0147s) ==============

sec: == [advisory_lock_connection] object_id: 164840, pg_backend_pid: 34646

How to set up and validate locally

  1. bundle exec rails db:migrate
  2. bundle exec rspec ee/spec/models/ai/governance/session_spec.rb

MR acceptance checklist

Evaluate this MR against the MR acceptance checklist.

Edited by Hitesh Raghuvanshi

Merge request reports

Loading
Loading