Add configured_at provenance map to Ai::NamespaceSetting
What does this MR do and why?
This is slice 2 of 4 of the epic Per-setting configuration provenance for group AI settings.
Resolves https://gitlab.com/gitlab-org/gitlab/-/work_items/629076.
Problem
Compliance controls need to know whether a group's AI settings were explicitly configured. Today we cannot tell.
The five typed columns on namespace_ai_settings are NOT NULL with defaults. A group that deliberately chose the default looks the same as a group that never touched the setting.
Checking whether an Ai::NamespaceSetting row exists does not work either. EE::Namespace#ai_settings is super || build_ai_settings with autosave: true. Any read followed by any namespace save persists a defaults-only row.
Change 1: migration (own commit)
Adds a configured_at jsonb column to namespace_ai_settings. It is NOT NULL with default {} and has a jsonb_typeof = 'object' check constraint. This mirrors the existing feature_settings column migration.
The table is small (table_size: small in db/docs), so a plain add_column inside with_lock_retries is fine. The five typed columns and every read path are untouched.
Change 2: stamping (own commit)
EE::Groups::UpdateService#prepare_params! now calls stamp_ai_settings_configured_at!. It looks at five tracked settings:
duo_workflow_mcp_enabledai_usage_data_collection_enabledai_catalog_restricted_to_group_hierarchyweb_search_enabledprompt_injection_protection_level
For each key present in params[:ai_settings_attributes], it writes configured_at[key] = Time.current.iso8601. Existing entries are kept and merged. Key presence means "explicitly configured". The value says when.
Why key presence instead of dirty tracking? Two reasons:
- On row creation every column shows
nil -> defaultinprevious_changes. That would stamp everything. - Re-submitting the current default is a no-op change, but it must still count as a deliberate choice.
Why is key presence meaningful? All ai_settings_attributes keys are Grape optional, so only caller-supplied keys reach the service. The group Duo settings Vue form submits through this same REST endpoint. Slice 1 (!255967 (merged)) change-gates that form so it no longer resubmits untouched keys.
Unrelated ai_settings writers never stamp because they do not go through group params. For example, Mutations::Ai::DomainSettings::NamespaceUpdate calls namespace.ai_settings.save directly. A spec asserts this.
Schema. app/validators/json_schemas/ai_namespace_setting_configured_at.json is strict: exactly the five keys, values must be date-time strings, and additionalProperties: false. It is validated on the model with a 64 KB size limit, like feature_settings.
Specs added.
- Model validation: empty, all five keys, unknown key rejected, non-date-time value rejected.
- Service: stamps a changed value, stamps a no-op default resubmission, preserves existing entries, no stamp for the untracked key
duo_agent_platform_enabled, no stamp whenai_settings_attributesis absent. - GraphQL mutation negative path.
Sequencing
This branch is based on the slice 1 branch sf/fix/change-gate-duo-settings-form-629075 and targets it. GitLab will retarget to master when slice 1 merges. This MR should merge after slice 1.
Deferred: the consumer switch
ComplianceManagement::ComplianceRequirements::ProjectFields#mcp_explicitly_configured? only exists on Draft !254023, not on master. Once this MR merges, that MR should check root_ancestor.ai_settings.configured_at.key?('duo_workflow_mcp_enabled') instead of ai_settings.persisted?. The statuses_info.js and docs wording check from the issue also belong there, because that control does not exist on master yet.
Database
Regular migration: add_column + add_check_constraint. Reversible. Verified locally with db:migrate:down and db:migrate:up.
This MR adds one new query. Previously, stamps were merged in Ruby. The code read configured_at, merged in the new stamp and wrote the whole column back on save. If two group updates ran at the same time and stamped different settings, one stamp could be lost. The merge now happens in SQL with jsonb ||, so concurrent saves add to each other instead of overwriting.
The query runs in an after_save on Ai::NamespaceSetting, inside the same transaction as group.save. It runs once for each group update that submits at least one of the five tracked settings in ai_settings_attributes. No other writes run it.
It updates a single row by namespace_id, which is the primary key. The table is small (table_size: small, about 71k rows in production).
update_all skips validations, and that is fine here. Rails validates the new value against the existing JSON schema before the query runs. The existing jsonb_typeof = 'object' check constraint still applies.
Update configured_at
UPDATE "namespace_ai_settings" SET configured_at = configured_at || '{"duo_workflow_mcp_enabled":"2026-09-28T12:00:00Z"}'::jsonb WHERE "namespace_ai_settings"."namespace_id" = 9970This plan comes from a Database Lab thin clone. The column does not exist in production yet, so I added it on the clone first:
ALTER TABLE namespace_ai_settings ADD COLUMN configured_at jsonb DEFAULT '{}'::jsonb NOT NULL- Column setup: https://console.postgres.ai/gitlab/gitlab-production-main/sessions/58842/commands/503089
- Plan: https://console.postgres.ai/gitlab/gitlab-production-main/sessions/58842/commands/503095
The plan uses an Index Scan on index_namespace_ai_settings_on_namespace_id and touches 1 row. It took 9.6 ms in total, 8.8 ms of which was cold I/O reads.
References
- Epic: https://gitlab.com/groups/gitlab-org/-/work_items/23594
- Issue: https://gitlab.com/gitlab-org/gitlab/-/work_items/629076
- Slice 1 (form change-gating): !255967 (merged)
- Consumer (Draft, compliance control): !254023
Screenshots or screen recordings
How to set up and validate locally
- Run the migration:
bin/rails db:migrate. - Open a Rails console. Pick a top-level group and an owner:
g = Group.find_by_full_path('...') u = g.owners.first - Check the starting state:
g.ai_settings.configured_atreturns{}. - Update a tracked setting.
falseis the default here, and it still stamps:Groups::UpdateService.new(g, u, ai_settings_attributes: { duo_workflow_mcp_enabled: false }).execute - Check again:
g.ai_settings.reload.configured_atreturns{"duo_workflow_mcp_enabled" => "<timestamp>"}. - Or use the REST API, then check again:
curl --request PUT --header "PRIVATE-TOKEN: $TOKEN" \ "http://gdk.test:3000/api/v4/groups/<id>?ai_settings_attributes[web_search_enabled]=true" - Negative check: run the
aiDomainSettingsNamespaceUpdateGraphQL mutation, org.ai_settings.update!(allowed_domains: ['example.com']). Confirmconfigured_atis unchanged.
MR acceptance checklist
This MR has been evaluated against the MR acceptance checklist.
