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-web

In 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 ignored

End 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=20260817120000

If 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 GovernanceSurface and the privilege clamp off this column. Must land before duo_client_executed_flow_governance, duo_workflow_background_tool_governance or duo_workflow_local_tool_governance goes 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 external surface stays open, tracked on #613544.

Merge request reports

Loading
Loading