Record how a Duo flow run is executed on the workflow
Why
Ai::DuoWorkflows::FlowExecutionAuthorizer decides whether a run is executed by the
caller or by GitLab in the background. Tool governance needs that answer, because it
selects which admin rule column applies (local_access vs background_access).
Today it does not get it. The surface is picked from the client-supplied environment
instead, so a client chooses its own rulebook. That gap is the subject of
#606343, and the agreed direction is in
this discussion:
key the surface off the execution classification, not off environment.
The blocker is availability. The classification exists only during the create request,
but the three places that resolve a surface later — the :ws connection mint,
WorkflowContextGenerationService and UpdateAgentPrivilegesService — hold a workflow
and nothing else. So the fact has to be on the record.
What this MR does
Adds duo_workflows_workflows.execution_mode (client / background, nullable) and
writes it in CreateWorkflowService from the sealed Classification. That is the only
writer, and :execution_mode is stripped from the request params so it cannot be set
from outside.
Nothing reads it yet, so behaviour is unchanged. The consumers land in the follow-up
that re-keys Ai::ToolRules::GovernanceSurface; the accessor for them is
workflow.executed_by_client?.
nil means nothing classified the run — chat-family flows, a missing catalog item,
creation paths that skip the authorizer, and rows predating the column. All keep today's
environment fallback, so no backfill is needed.
How to verify
Needs a GDK with Duo already set up.
bundle exec rails db:migrate && gdk restart rails-webIn rails console, the writer and both spoof routes:
Feature.enable(:duo_client_executed_flow_governance)
c = Ai::DuoWorkflows::FlowExecutionAuthorizer::Classification
mode = ->(execution, extra = {}) do
Ai::DuoWorkflows::CreateWorkflowService.new(
container: Project.first, current_user: User.find_by_username('root'),
params: { environment: 'ide', workflow_definition: 'developer/v1',
goal: 'probe' }.merge(extra),
execution: execution
).execute[:workflow]&.execution_mode
end
mode.(c::CLIENT) # => "client"
mode.(c::BACKGROUND) # => "background"
mode.(nil) # => nil
mode.(:client) # => nil, not a sealed Classification
mode.(nil, execution_mode: :background) # => nil, request params are ignoredEnd to end, a CLI-shaped session through the real endpoint:
curl --request POST --header "PRIVATE-TOKEN: $TOKEN" \
"http://gdk.test:3000/api/v4/ai/duo_workflows/workflows" \
--data "project_id=<ID>&workflow_definition=developer/v1&environment=ide&goal=probe"Ai::DuoWorkflows::Workflow.last.execution_mode should be "client", and still
"client" if you add &execution_mode=background to the same call.
Cleanup
Roll back before switching branches, or the migration file is gone:
bundle exec rails db:migrate:down VERSION=20260817120000If you already switched, drop it by hand in rails console:
ActiveRecord::Base.connection.execute("ALTER TABLE duo_workflows_workflows DROP COLUMN IF EXISTS execution_mode")
ActiveRecord::Base.connection.execute("DELETE FROM schema_migrations WHERE version = '20260817120000'")Follow-ups
- Re-key
GovernanceSurfaceand the privilege clamp off this column. Must land beforeduo_client_executed_flow_governance,duo_workflow_background_tool_governanceorduo_workflow_local_tool_governancegoes GA. - Classify the remaining background creation paths (flow triggers, catalog execution, create-and-start). They need routing through the authorizer, which changes their authorization and service-account resolution, so it is not a labelling change.
- The
externalsurface stays open, tracked on #613544.