Scope BLSA merge request scans to changed files

Targets master. Its base, !257092 (merged), merged on 2026-09-25. !257519 (merged) is stacked on this MR and merges after it.

What does this MR do and why?

Problem: without this MR every merge request scan reviews the whole repository — slower, costlier, and it reports findings unrelated to the change.

  • When the goal is a merge request IID and the run checks out a branch other than the default branch, the Business Logic Security Scan now scans only the files the merge request adds or modifies.
  • It refuses a merge request that only deletes files, since nothing is left to scan.
  • It adds two runtime dials from project CI/CD variables: BL_TARGET_FILES and BL_SCAN_EFFORT (low, standard or high; anything else is logged and sent as low). They are sent in a new BLSA-only additional-context category, agent_platform_bl_security_context (fields scan_effort and target_files), not in the shared standard context. All of it sits behind the bl_security_analyzer flag (default off).

Part of the BLSA split of !246889. Tracker: https://gitlab.com/gitlab-org/gitlab/-/work_items/630266. The engine counterpart is ai-assist gitlab-org/modelops/applied-ml/code-suggestions/ai-assist!7077 (merged), which reads the new agent_platform_bl_security_context category; it has not merged yet. Both sides are behind flags: this MR behind bl_security_analyzer, and the engine flow's fan-out stages behind the dap_for_each beta flag.

Behavior

Case Without this MR With this MR
MR pipeline with a small diff Whole repository Only the files the MR's latest diff adds or modifies. Renamed files count under their new path. Deleted files are not scanned
MR IID run that checks out the default branch (API or Duo Chat, no source branch) Whole repository Whole repository. BL_TARGET_FILES and the MR diff apply only when the run checks out a branch other than the default branch, so default-branch runs are never partial
More than 400 added or modified files Whole repository Whole repository. target_files is left out and the reason is logged
Diff overflowed (or not collected) Whole repository Whole repository. target_files is left out and the reason is logged. The scan is never partial
A changed path contains whitespace, a comma or a semicolon (the engine splits the list on those) Whole repository Whole repository. target_files is left out, reason: unsupported_path is logged
MR that only deletes files Runs a whole-repository scan Refused before a workflow exists, reason: :no_files_to_scan, including when BL_TARGET_FILES is set or the run checks out the default branch
Deletion-only MR whose diff overflowed Whole repository Whole repository: its paths are unknown, so it can't be recognized as deletion-only
BL_TARGET_FILES set Ignored Used as target_files when the run checks out a branch other than the default branch; the MR diff is then not read. Ignored on default-branch runs. A deletion-only MR is still refused
BL_SCAN_EFFORT unset or blank No scan_effort sent scan_effort: low
BL_SCAN_EFFORT set to low, standard or high (any case, surrounding spaces allowed) No scan_effort sent Sent as scan_effort, stripped and lowercased
BL_SCAN_EFFORT set to anything else No scan_effort sent scan_effort: low, reason: invalid_scan_effort is logged

A goal that is not an MR IID (a pipeline URL, used for a fork's MR) gets no target_files and logs nothing. Most such runs are refused; a goal that resolves to no pipeline or MR runs a whole-repository scan.

The diff is used only when the checked-out branch is the MR's own source branch and the MR isn't from a fork. Otherwise target_files is left out and a reason is logged.

How to review

  • BlSecurity::TargetFiles.for_goal: reads the paths from merge_request_diff_files, skipping deleted files. It plucks at most 401 rows. new_path falls back to old_path when stored blank. A path with whitespace, a comma or a semicolon returns :unsupported_path (whole-repository scan).
  • BlSecurityContextBuilder.build: returns nil (no category) unless the flow is this one and the flag is on. It reads only the two variables, unprotected and not file-typed, in the * environment scope. It validates and normalizes scan_effort against the engine's tiers. It derives target_files from the MR diff only when the goal is an MR IID and the run checks out a branch other than the default branch.
  • BuildAdditionalContextService#with_bl_security_context: appends the category when the builder returns fields. A caller-supplied envelope with this category is always dropped, like the other Rails-controlled categories.
  • The no_files_to_scan refusal in the goal validator. It runs after the flag check and the fork and no-MR refusals.

Backward compatibility / impact on existing flows

Shared code Change Risk Mitigation
StandardContextBuilder and agent_platform_standard_context/1.1.0.json None: both are byte-identical to the base None The dials live in their own category
BuildAdditionalContextService New optional workflow_definition: and goal: kwargs; appends agent_platform_bl_security_context for this flow with the flag on An extra query or a new envelope for other flows Both kwargs default to nil. The builder returns nil before any query unless the flow is this one and the flag is on; specs assert the context is identical to one built without the flow for other flows and with the flag off
StartWorkflowService Passes the workflow definition and goal to BuildAdditionalContextService None beyond the above Other flows get the same context as before
agent_platform_bl_security_context/1.0.0.json (new schema) scan_effort (required, low/standard/high) and target_files (optional string), additionalProperties: false, like the other per-flow category schemas An engine that does not know the category skips it with a warning The engine counterpart (ai-assist !7077) reads it

Database queries

Plans from Database Lab (postgresai joe explain, production clones, 2026-09-25).

1. Changed paths of the MR's latest diff (TargetFiles.for_goal)

SELECT "merge_request_diff_files"."new_path", "merge_request_diff_files"."old_path"
FROM "merge_request_diff_files"
WHERE "merge_request_diff_files"."merge_request_diff_id" = $1
  AND "merge_request_diff_files"."deleted_file" = FALSE
ORDER BY "merge_request_diff_files"."merge_request_diff_id" ASC,
         "merge_request_diff_files"."relative_order" ASC
LIMIT 401
  • Index: merge_request_diff_files_pkey, the primary key (merge_request_diff_id, relative_order). The table is range-partitioned on merge_request_diff_id, so the query hits one partition. The ORDER BY comes from the has_many :merge_request_diff_files scope and matches the key. deleted_file is a filter on at most one diff's rows, capped by LIMIT 401.
  • Plan (gitlab-production-main, a 91-file diff): Limit -> Index Scan using index_6ed6e79676 on merge_request_diff_files_2000000001 (Index Cond: merge_request_diff_id = …, Filter: NOT deleted_file). 9.8 ms total (planning 1.5 ms, execution 8.3 ms), 91 rows.
  • How often: once per business logic security scan run with bl_security_analyzer on, when the goal is a merge request IID. The goal validator and BlSecurityContextBuilder both ask, and the result is memoized in Gitlab::SafeRequestStore, so it runs once per request.

2. Scan dials from project CI/CD variables (BlSecurityContextBuilder.build)

SELECT "ci_variables".*
FROM "ci_variables"
WHERE "ci_variables"."project_id" = $1
  AND "ci_variables"."protected" = FALSE
  AND "ci_variables"."variable_type" = 1
  AND "ci_variables"."environment_scope" = '*'
  AND "ci_variables"."key" IN ('BL_SCAN_EFFORT', 'BL_TARGET_FILES')
  • Index: index_ci_variables_on_project_id_and_key_and_environment_scope, the unique index on (project_id, key, environment_scope). Returns at most 2 rows.
  • Plan (gitlab-production-ci, project 278964): Index Scan using index_ci_variables_on_project_id_and_key_and_environment_scope (Index Cond: project_id, key = ANY(…), environment_scope = '*'). 5.4 ms total (planning 1.5 ms, execution 3.8 ms). The added protected / variable_type filters apply on the same index scan (at most 2 rows).
  • How often: once per business logic security scan workflow start with bl_security_analyzer on. Other flows, and runs with the flag off, skip it.

Design decisions

  • The fallback is a whole-repository scan, never a partial one. A partial scan would look complete. A large MR costs a full scan.
  • BL_TARGET_FILES wins over the MR diff. It is an explicit project override.
  • Only added and modified files are scanned. A deletion can still break a check that lives in another file; that file is scanned only if the MR also changes it.
  • A deletion-only MR is refused, even with BL_TARGET_FILES set or on a default-branch run. There is no change to review, so no scan starts.
  • BL_TARGET_FILES and the MR diff apply only when the run checks out a branch other than the default branch; default-branch runs are always whole-repository scans. A default-branch run is ingested as a full scan, so it must not be partial.
  • MR scans use the MR's latest diff. The workload checks out the branch head at start, not the pipeline SHA, so the file list and the checkout match. A later push gets its own pipeline and scan.
  • scan_effort defaults to low. It keeps the automatic scan cheap. A project sets another tier with BL_SCAN_EFFORT.
  • Only the * environment scope is read. A workflow has no environment when it starts.
  • An unknown scan_effort becomes low. Matches the engine's tiers (it strips and downcases); logged once so a typo is visible.
  • The dials live in a BLSA-only category, agent_platform_bl_security_context, built at workflow start (in BuildAdditionalContextService, called from StartWorkflowService), not in the shared standard context and not through the flow's additional_context_resolver. That hook runs only on the trigger path, not on API, Duo Chat, restart or resume starts, which would then lose the dials. A follow-up could make that hook run on all paths, and move this category onto it.

Not in this MR (later in the stack)

  • API and Duo Chat runs with an MR IID check out the source branch: !257519 (merged). Until it merges, those runs check out the default branch and stay a whole-repository scan.
File inventory (12 files)
  • Scope: ee/app/models/ai/catalog/foundational_flow/bl_security/target_files.rb (new)
  • Refusal: ee/app/models/ai/catalog/foundational_flow/bl_security/definition.rb
  • Comment: ee/app/models/ai/catalog/goal_templates/bl_security.rb
  • BLSA context category: ee/app/services/ai/duo_workflows/additional_context/bl_security_context_builder.rb (new), app/validators/json_schemas/agent_platform/agent_platform_bl_security_context/1.0.0.json (new), ee/app/services/ai/duo_workflows/build_additional_context_service.rb, ee/app/services/ai/duo_workflows/start_workflow_service.rb
  • Specs: target_files_spec.rb (new), bl_security_context_builder_spec.rb (new), foundational_flow_spec.rb, build_additional_context_service_spec.rb, start_workflow_service_spec.rb

MR acceptance checklist

Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.

Edited by Meir Benayoun

Merge request reports

Loading
Loading