diff --git a/apps/backend/alembic/versions/0047_scan_active_index_per_ref.py b/apps/backend/alembic/versions/0047_scan_active_index_per_ref.py
new file mode 100644
index 0000000..18be1d2
--- /dev/null
+++ b/apps/backend/alembic/versions/0047_scan_active_index_per_ref.py
@@ -0,0 +1,81 @@
+# SPDX-License-Identifier: Apache-2.0
+# Copyright 2026 TRUSCA contributors
+"""allow one in-flight scan per branch instead of one per project
+
+Revision ID: 0047
+Revises: 0046
+Create Date: 2026-08-04
+
+Phase: branch as a first-class axis (follows 0046)
+Kind: schema (replaces one partial unique index; no data migration)
+Forward-only: yes
+
+What:
+ ``ix_scans_project_active`` is rebuilt on ``(project_id, ref)`` with
+ ``NULLS NOT DISTINCT``, keeping its ``status IN ('queued','running')``
+ predicate.
+
+Why:
+ The index enforced at most one in-flight scan per PROJECT. Once branches
+ became a first-class axis — the current-state anchor prefers the main line,
+ the reads filter by ref — that limit stopped matching the model: pushing to
+ ``main`` and ``release/1.x`` at the same time made one CI job wait for the
+ other's scan, or fail with a 409 it could do nothing about. The branches
+ write to disjoint snapshots, so nothing about the data required them to be
+ serialized.
+
+Why NULLS NOT DISTINCT:
+ ``ref`` is NULL for ad-hoc scans, which is the majority of rows. Under the
+ default NULLS DISTINCT a plain ``(project_id, ref)`` unique index would stop
+ constraining them at all — every manual re-trigger would queue another scan,
+ turning a stability guard into nothing for the common case. NULLS NOT
+ DISTINCT (PostgreSQL 15+; we pin 17) makes all ref-less rows of a project
+ collide with each other, so ad-hoc scans keep exactly today's behaviour and
+ only *named branches* gain concurrency.
+
+Blast radius:
+ A project can now hold as many in-flight scans as it has distinct refs. The
+ bound that matters is the per-team cap (``_enforce_team_concurrency_cap``)
+ and the per-user trigger rate limit, both unchanged; the disk guard still
+ refuses new work when the workspace is full.
+
+Locking / build strategy:
+ DROP then CREATE inside the migration transaction. The guard is absent for
+ that window, which is safe because ``scripts/upgrade.sh`` stops the app
+ containers before migrating — nothing can trigger a scan meanwhile. Doing it
+ the other way (create first, drop after) is impossible: the two indexes would
+ both have to hold, and the old one is exactly what we are relaxing.
+
+Migration policy (CLAUDE.md §6):
+ - No column changes, no backfill, no data migration.
+ - Forward-only: ``downgrade()`` raises ``NotImplementedError``.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+from alembic import op
+
+revision: str = "0047"
+down_revision: str | None = "0046"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ op.execute("DROP INDEX IF EXISTS ix_scans_project_active")
+ # Mirrors the Index(...) declaration in models.scan.Scan.__table_args__ —
+ # keep the two in step or ``alembic check`` reports the schema as drifted.
+ op.execute(
+ """
+ CREATE UNIQUE INDEX ix_scans_project_active
+ ON scans (project_id, ref)
+ NULLS NOT DISTINCT
+ WHERE status IN ('queued','running')
+ """
+ )
+
+
+def downgrade() -> None:
+ raise NotImplementedError("forward-only migration (CLAUDE.md §6)")
diff --git a/apps/backend/models/scan.py b/apps/backend/models/scan.py
index 2436f1f..b1a7fc9 100644
--- a/apps/backend/models/scan.py
+++ b/apps/backend/models/scan.py
@@ -381,13 +381,20 @@ class Scan(Base):
Index("ix_scans_celery_task_id", "celery_task_id"),
# JSONB GIN — supports `metadata @> '{...}'` (e.g. "find scans of branch X").
Index("ix_scans_metadata_gin", "metadata", postgresql_using="gin"),
- # Concurrency gate: at most one scan per project may be queued or
- # running at any time. Mirrored in the migration via op.execute()
- # because partial unique indexes need explicit DDL.
+ # Concurrency gate: at most one in-flight scan per (project, branch).
+ # Two branches write disjoint snapshots, so serializing them only made
+ # one CI job wait on another's push. NULLS NOT DISTINCT is what keeps
+ # this a guard rather than a formality: ``ref`` is NULL for ad-hoc
+ # scans (most rows), and under the default NULLS DISTINCT those would
+ # stop colliding entirely and a project could queue unbounded manual
+ # re-triggers. Migration 0047 mirrors this via op.execute() — partial
+ # unique indexes need explicit DDL.
Index(
"ix_scans_project_active",
"project_id",
+ "ref",
unique=True,
+ postgresql_nulls_not_distinct=True,
postgresql_where=text("status IN ('queued','running')"),
),
# W6-#42 — rematch beat's "due succeeded scans" hot path. Partial on
diff --git a/apps/backend/schemas/project_detail.py b/apps/backend/schemas/project_detail.py
index 43ee81d..144f1e1 100644
--- a/apps/backend/schemas/project_detail.py
+++ b/apps/backend/schemas/project_detail.py
@@ -86,6 +86,16 @@ class ScanSummary(BaseModel):
"renders). ``null`` when the scan was run without a release label."
),
)
+ ref: str | None = Field(
+ default=None,
+ description=(
+ "Normalized git ref this scan targeted (``main``, ``pr-12``), or "
+ "``null`` for an ad-hoc trigger that carried none. The concurrency "
+ "gate is per-(project, ref), so a client deciding whether its own "
+ "trigger would conflict has to compare against this, not merely "
+ "against 'some scan is active'."
+ ),
+ )
@model_validator(mode="before")
@classmethod
diff --git a/apps/backend/services/scan_service.py b/apps/backend/services/scan_service.py
index ec4c76b..42c5030 100644
--- a/apps/backend/services/scan_service.py
+++ b/apps/backend/services/scan_service.py
@@ -9,10 +9,12 @@
inside `trigger_scan` flags the exact insertion point.
Concurrency contract (CLAUDE.md core rule #3 + models/scan.py partial unique
-index `ix_scans_project_active`): at most one scan per project may be in
-state queued|running. The DB rejects a second INSERT with IntegrityError; we
-translate that to `ScanInProgressConflict` (409) so callers get a stable RFC
-7807 envelope instead of a Python traceback.
+index `ix_scans_project_active`): at most one scan per (project, branch) may
+be in state queued|running, where all ref-less ad-hoc scans of a project count
+as one branch. Two branches write disjoint snapshots, so they run in parallel;
+re-triggering the same branch still conflicts. The DB rejects the second
+INSERT with IntegrityError; we translate that to `ScanInProgressConflict` (409)
+so callers get a stable RFC 7807 envelope instead of a Python traceback.
"""
from __future__ import annotations
@@ -108,6 +110,19 @@ class ScanInProgressConflict(ScanError):
title = "Scan Already In Progress"
+def _in_progress_detail(project_id: object, ref: str | None) -> str:
+ """Name the branch that is busy, not just the project.
+
+ Since the concurrency gate became per-(project, ref), "a scan is already
+ running for this project" would send the caller looking for a conflict that
+ may be on a branch they are not touching. The ref-less case keeps the old
+ wording because there is no branch to name.
+ """
+ if ref:
+ return f"a scan is already queued or running for {ref} in project {project_id}"
+ return f"a scan is already queued or running for project {project_id}"
+
+
class ScanArchivedConflict(ScanError):
"""409 — the project is archived; archiving disables new scans (H-7).
@@ -336,8 +351,8 @@ async def _enforce_team_concurrency_cap(
"""Raise :class:`ConcurrentScanLimitExceeded` if the team is at the cap.
A cap of 0 (or negative) disables the check entirely — the operator has
- opted out and only the per-project unique index + per-user rate limit
- apply.
+ opted out and only the per-(project, branch) unique index + per-user rate
+ limit apply.
Note (race window — soft cap): this SELECT-then-INSERT is not atomic
across concurrent triggers from the same team. N requests can each read
@@ -347,9 +362,10 @@ async def _enforce_team_concurrency_cap(
M2 (security review): worst-case bound. The overshoot is bounded, not
unbounded, by two independent controls:
- * the per-project unique partial index (``ix_scans_project_active``)
- guarantees at most ONE active scan per project, so a single project
- can never contribute more than 1 to the overshoot; and
+ * the unique partial index (``ix_scans_project_active``) guarantees at
+ most ONE active scan per (project, branch), so a project contributes at
+ most one per branch it is being pushed to rather than an unbounded
+ number of re-triggers; and
* the per-user scan-trigger rate limit (``SCAN_TRIGGER_RATE_LIMIT``,
default 20/min) bounds how many triggers any one member can fire in
the race window.
@@ -708,7 +724,7 @@ async def trigger_scan(
try:
await session.flush()
except IntegrityError as exc:
- # The partial unique index on (project_id) WHERE status IN
+ # The partial unique index on (project_id, ref) WHERE status IN
# ('queued','running') is the canonical signal. Postgres returns the
# constraint name in the orig message; we don't switch on it because
# the only realistic constraint that fires from this INSERT is the
@@ -716,7 +732,7 @@ async def trigger_scan(
# exists.
await session.rollback()
raise ScanInProgressConflict(
- f"a scan is already queued or running for project {project_id_value}",
+ _in_progress_detail(project_id_value, scan.ref),
) from exc
# I-2: keep the project.latest_scan_id pointer in sync so list pages
@@ -735,7 +751,7 @@ async def trigger_scan(
# possible if the txn was held briefly). Translate identically.
await session.rollback()
raise ScanInProgressConflict(
- f"a scan is already queued or running for project {project_id_value}",
+ _in_progress_detail(project_id_value, scan.ref),
) from exc
await session.refresh(scan)
diff --git a/apps/backend/tests/integration/test_scans_api.py b/apps/backend/tests/integration/test_scans_api.py
index be8bccb..352e504 100644
--- a/apps/backend/tests/integration/test_scans_api.py
+++ b/apps/backend/tests/integration/test_scans_api.py
@@ -368,6 +368,92 @@ async def test_concurrent_trigger_returns_409_problem(client) -> None:
assert body.get("scan_already_in_progress") is True
+async def test_two_branches_scan_concurrently(client) -> None:
+ """The gate is per-(project, branch): disjoint branches must not serialize."""
+ team, user, project = await _seed(client, role="developer")
+ headers = _bearer_for(user)
+
+ first = await client.post(
+ f"/v1/projects/{project.id}/scans",
+ headers=headers,
+ json={"kind": "source", "metadata": {"ref": "refs/heads/main"}},
+ )
+ assert first.status_code == 202, first.text
+
+ second = await client.post(
+ f"/v1/projects/{project.id}/scans",
+ headers=headers,
+ json={"kind": "source", "metadata": {"ref": "refs/heads/release/1.x"}},
+ )
+ assert second.status_code == 202, second.text
+ assert first.json()["ref"] == "main"
+ assert second.json()["ref"] == "release/1.x"
+
+
+async def test_same_branch_retrigger_still_conflicts(client) -> None:
+ # Relaxing the gate must not make it a formality — the same branch twice is
+ # still duplicate work on the same snapshot.
+ team, user, project = await _seed(client, role="developer")
+ headers = _bearer_for(user)
+
+ first = await client.post(
+ f"/v1/projects/{project.id}/scans",
+ headers=headers,
+ json={"kind": "source", "metadata": {"ref": "refs/heads/main"}},
+ )
+ assert first.status_code == 202, first.text
+
+ # A bare branch name must collide with the fully-qualified form the first
+ # trigger used — both normalize to `main`.
+ second = await client.post(
+ f"/v1/projects/{project.id}/scans",
+ headers=headers,
+ json={"kind": "source", "metadata": {"ref": "main"}},
+ )
+ assert second.status_code == 409, second.text
+ body = second.json()
+ assert body.get("scan_already_in_progress") is True
+ # The detail names the busy branch so the caller does not go looking for a
+ # conflict on a branch they never touched.
+ assert "main" in body["detail"]
+
+
+async def test_adhoc_scans_still_serialize_with_each_other(client) -> None:
+ # ref is NULL for ad-hoc triggers. Under the default NULLS DISTINCT the
+ # relaxed index would stop constraining them at all, so this pins the
+ # NULLS NOT DISTINCT half of the migration.
+ team, user, project = await _seed(client, role="developer")
+ headers = _bearer_for(user)
+
+ first = await client.post(
+ f"/v1/projects/{project.id}/scans", headers=headers, json={"kind": "source"}
+ )
+ assert first.status_code == 202, first.text
+
+ second = await client.post(
+ f"/v1/projects/{project.id}/scans", headers=headers, json={"kind": "source"}
+ )
+ assert second.status_code == 409, second.text
+ assert second.json().get("scan_already_in_progress") is True
+
+
+async def test_adhoc_scan_does_not_block_a_branch_scan(client) -> None:
+ team, user, project = await _seed(client, role="developer")
+ headers = _bearer_for(user)
+
+ adhoc = await client.post(
+ f"/v1/projects/{project.id}/scans", headers=headers, json={"kind": "source"}
+ )
+ assert adhoc.status_code == 202, adhoc.text
+
+ branch = await client.post(
+ f"/v1/projects/{project.id}/scans",
+ headers=headers,
+ json={"kind": "source", "metadata": {"ref": "refs/heads/main"}},
+ )
+ assert branch.status_code == 202, branch.text
+
+
async def test_trigger_on_archived_project_returns_409(client) -> None:
"""H-7: an archived project must reject new scans, not silently accept them."""
from datetime import UTC, datetime
diff --git a/apps/frontend/src/features/projects/ProjectDetailPage.tsx b/apps/frontend/src/features/projects/ProjectDetailPage.tsx
index 9820648..2159d59 100644
--- a/apps/frontend/src/features/projects/ProjectDetailPage.tsx
+++ b/apps/frontend/src/features/projects/ProjectDetailPage.tsx
@@ -270,6 +270,15 @@ export function ProjectDetailPage() {
const activeScan = (overview.data?.recent_scans ?? []).find(
(scan) => scan.status === "queued" || scan.status === "running",
);
+ // The concurrency gate is per-(project, branch), and the Scan button always
+ // triggers an ad-hoc (ref-less) run — so only another ad-hoc scan can block
+ // it. A CI scan on `main` leaves the button usable; disabling on "any active
+ // scan" would hide the concurrency the gate now allows.
+ const blockingScan = (overview.data?.recent_scans ?? []).find(
+ (scan) =>
+ (scan.status === "queued" || scan.status === "running") &&
+ scan.ref == null,
+ );
if (!projectId) {
return (
@@ -493,6 +502,7 @@ export function ProjectDetailPage() {
demoReadOnly={writesDisabled}
onScan={() => setSourceDialogOpen(true)}
activeScan={activeScan ?? null}
+ blockingScan={blockingScan ?? null}
onReopenActiveScan={
activeScan ? () => handleReopenScan(activeScan) : undefined
}
@@ -768,6 +778,8 @@ interface ProjectDetailHeaderProps {
* progress drawer never strands the user.
*/
activeScan: ScanSummary | null;
+ /** The active ad-hoc scan that would actually conflict with the button. */
+ blockingScan: ScanSummary | null;
/** Re-open the live progress drawer for {@link activeScan}. */
onReopenActiveScan?: () => void;
/** Currently pinned scan id (`?scan=`), or undefined for the live view. */
@@ -792,6 +804,7 @@ function ProjectDetailHeader({
demoReadOnly,
onScan,
activeScan,
+ blockingScan,
onReopenActiveScan,
pinnedScanId,
latestScanId,
@@ -905,11 +918,11 @@ function ProjectDetailHeader({
diff --git a/apps/frontend/src/features/projects/api/projectDetailApi.ts b/apps/frontend/src/features/projects/api/projectDetailApi.ts
index 49c0192..b09fa83 100644
--- a/apps/frontend/src/features/projects/api/projectDetailApi.ts
+++ b/apps/frontend/src/features/projects/api/projectDetailApi.ts
@@ -61,6 +61,12 @@ export interface ScanSummary {
* without a release label.
*/
release: string | null;
+ /**
+ * Normalized git ref the scan targeted, or `null` for an ad-hoc trigger.
+ * The concurrency gate is per-(project, ref), so "is my trigger blocked?"
+ * has to compare refs rather than just checking for any active scan.
+ */
+ ref: string | null;
}
export interface ProjectOverviewResponse {
diff --git a/apps/frontend/tests/unit/ProjectDetailPage.test.tsx b/apps/frontend/tests/unit/ProjectDetailPage.test.tsx
index 0830d71..519bc17 100644
--- a/apps/frontend/tests/unit/ProjectDetailPage.test.tsx
+++ b/apps/frontend/tests/unit/ProjectDetailPage.test.tsx
@@ -99,7 +99,7 @@ function makeProject(): ProjectPublic {
};
}
-function scan(status: string, id: string): ScanSummary {
+function scan(status: string, id: string, ref: string | null = null): ScanSummary {
return {
id,
kind: "source",
@@ -109,6 +109,7 @@ function scan(status: string, id: string): ScanSummary {
completed_at: null,
created_at: "2026-05-26T00:00:00Z",
release: null,
+ ref,
};
}
@@ -196,4 +197,26 @@ describe("ProjectDetailPage active-scan chip (#29)", () => {
// …but no active-scan chip, because nothing is queued/running.
expect(screen.queryByTestId("project-detail-active-scan")).not.toBeInTheDocument();
});
+
+ it("disables Scan while an ad-hoc scan is in flight", async () => {
+ mockedUseOverview.mockReturnValue(overviewWith([scan("running", "scan-adhoc")]));
+ renderPage();
+ const button = await screen.findByTestId("project-detail-scan");
+ expect(button).toBeDisabled();
+ expect(button).toHaveAttribute("data-scan-blocked", "active");
+ });
+
+ it("leaves Scan usable while a branch scan runs — it cannot conflict", async () => {
+ // The gate is per-(project, branch) and this button triggers an ad-hoc
+ // run, so a CI scan on main must not grey it out.
+ mockedUseOverview.mockReturnValue(
+ overviewWith([scan("running", "scan-main", "main")]),
+ );
+ renderPage();
+ const button = await screen.findByTestId("project-detail-scan");
+ expect(button).toBeEnabled();
+ expect(button).not.toHaveAttribute("data-scan-blocked");
+ // The chip still reports the branch scan — it is informational, not a lock.
+ expect(screen.getByTestId("project-detail-active-scan")).toBeInTheDocument();
+ });
});
diff --git a/apps/frontend/tests/unit/features/projects/OverviewTab.test.tsx b/apps/frontend/tests/unit/features/projects/OverviewTab.test.tsx
index 74a4c20..fda8cc0 100644
--- a/apps/frontend/tests/unit/features/projects/OverviewTab.test.tsx
+++ b/apps/frontend/tests/unit/features/projects/OverviewTab.test.tsx
@@ -338,6 +338,7 @@ describe("OverviewTab", () => {
completed_at: "2026-05-01T12:01:30Z",
created_at: "2026-05-01T12:00:00Z",
release: null,
+ ref: null,
},
],
}),
@@ -368,6 +369,7 @@ describe("OverviewTab", () => {
completed_at: null,
created_at: "2026-05-01T12:00:00Z",
release: null,
+ ref: null,
},
],
}),
@@ -396,6 +398,7 @@ describe("OverviewTab", () => {
completed_at: "2026-05-01T12:01:30Z",
created_at: "2026-05-01T12:00:00Z",
release: null,
+ ref: null,
},
],
}),
diff --git a/apps/frontend/tests/unit/features/projects/RecentScansTable.test.tsx b/apps/frontend/tests/unit/features/projects/RecentScansTable.test.tsx
index 4be58ea..7090454 100644
--- a/apps/frontend/tests/unit/features/projects/RecentScansTable.test.tsx
+++ b/apps/frontend/tests/unit/features/projects/RecentScansTable.test.tsx
@@ -17,6 +17,7 @@ function scan(overrides: Partial = {}): ScanSummary {
completed_at: "2026-05-01T12:01:30Z",
created_at: "2026-05-01T12:00:00Z",
release: null,
+ ref: null,
...overrides,
};
}
diff --git a/docs-site/docs/user-guide/scans.md b/docs-site/docs/user-guide/scans.md
index 606febc..26efcc7 100644
--- a/docs-site/docs/user-guide/scans.md
+++ b/docs-site/docs/user-guide/scans.md
@@ -41,8 +41,12 @@ The scan dialog has a **Verbose logs (debug)** toggle (off by default). Leave it
A right-slide drawer opens on the project list page with a live progress view backed by a WebSocket connection. You can close the tab — the scan continues on the worker. Reopen the project and reconnect at any time. While a scan is `queued` or `running`, the drawer carries a **Cancel scan** action — see [Cancel a scan](#cancel-a-scan).
-:::note Only one scan at a time per project
-If a project already has a `queued` or `running` scan, the **Scan** button is disabled on the project detail header and its tooltip points you at the in-progress chip in the header (clicking the chip re-opens the existing scan's progress drawer). Triggering a second scan via the API returns `409 Conflict` with the RFC 7807 extension `scan_already_in_progress: true` — wait for the active scan to reach a terminal state, or **Cancel** it, before starting another. The same guard applies to UI, API, and CI clients.
+:::note One scan at a time per branch
+A project can scan several branches at once — pushing to `main` and `release/1.x` together no longer makes one CI job wait on the other, because the two write to separate snapshots. What is still serialized is the *same* branch: triggering a second scan for a branch that already has one `queued` or `running` returns `409 Conflict` with the RFC 7807 extension `scan_already_in_progress: true`, and the detail names the branch that is busy.
+
+Scans triggered without a ref — the **Scan** button, and API calls that omit `metadata.ref` — all count as one bucket, so two of those still conflict with each other. That is why the button greys out only while another ad-hoc scan is in flight; a CI scan on `main` leaves it usable, and the in-progress chip beside it stays visible either way (clicking it re-opens that scan's drawer).
+
+Wait for the conflicting scan to reach a terminal state, or **Cancel** it, before re-triggering. The same guard applies to UI, API, and CI clients.
:::

@@ -381,7 +385,7 @@ The scan reached a terminal state (`succeeded` / `failed` / `cancelled`) between
### A second scan won't start — the **Scan** button is greyed out
-The project already has a `queued` or `running` scan. Only one active scan per project is allowed. Open the in-progress chip in the project header (or the row in the global queue) to see the existing run, wait for it to finish, or **Cancel** it before starting another. See [Only one scan at a time per project](#from-the-ui).
+The project already has an ad-hoc (`queued` or `running`) scan, and the button triggers another of the same kind. Only one active scan per branch is allowed, and every ref-less trigger shares one bucket. Open the in-progress chip in the project header (or the row in the global queue) to see the existing run, wait for it to finish, or **Cancel** it before starting another. A scan running on a named branch does not disable the button — see [One scan at a time per branch](#from-the-ui).
### A completed scan's drawer shows a spinner that never finishes
diff --git a/docs-site/i18n/ko/docusaurus-plugin-content-docs/current/user-guide/scans.md b/docs-site/i18n/ko/docusaurus-plugin-content-docs/current/user-guide/scans.md
index d1bc11d..512386f 100644
--- a/docs-site/i18n/ko/docusaurus-plugin-content-docs/current/user-guide/scans.md
+++ b/docs-site/i18n/ko/docusaurus-plugin-content-docs/current/user-guide/scans.md
@@ -43,8 +43,12 @@ sidebar_position: 2

-:::note 프로젝트당 동시 스캔은 하나
-프로젝트에 이미 `queued` 또는 `running` 스캔이 있으면 프로젝트 상세 헤더의 **Scan** 버튼이 비활성화되고, 헤더의 진행중 칩(클릭 시 기존 스캔의 진행 드로어 재오픈)을 가리키는 툴팁이 표시됩니다. API로 두 번째 스캔을 트리거하면 `409 Conflict`와 RFC 7807 확장 필드 `scan_already_in_progress: true`를 반환합니다 — 활성 스캔이 종료 상태에 도달하거나 **Cancel** 한 뒤 다시 시작하세요. 동일한 가드가 UI·API·CI 클라이언트에 적용됩니다.
+:::note 브랜치당 동시 스캔은 하나
+한 프로젝트에서 여러 브랜치를 동시에 스캔할 수 있습니다. `main`과 `release/1.x`에 함께 push해도 한쪽 CI가 다른 쪽 스캔을 기다리지 않습니다. 두 브랜치는 서로 다른 스냅샷에 쓰기 때문입니다. 여전히 직렬화되는 것은 같은 브랜치입니다. 이미 `queued` 또는 `running` 스캔이 있는 브랜치에 두 번째 스캔을 트리거하면 `409 Conflict`와 RFC 7807 확장 필드 `scan_already_in_progress: true`를 반환하고, detail에 어느 브랜치가 사용 중인지 밝힙니다.
+
+ref 없이 트리거한 스캔은 — **Scan** 버튼과 `metadata.ref`를 생략한 API 호출 — 모두 한 묶음으로 셉니다. 그래서 그중 둘은 여전히 서로 충돌합니다. 버튼이 비활성화되는 것도 다른 ref 없는 스캔이 실행 중일 때뿐이고, `main`의 CI 스캔이 도는 중에는 버튼을 쓸 수 있습니다. 진행중 칩은 어느 경우에도 그대로 보이며, 클릭하면 해당 스캔의 드로어가 열립니다.
+
+충돌한 스캔이 종료 상태에 도달하거나 **Cancel** 한 뒤 다시 트리거하세요. 동일한 가드가 UI·API·CI 클라이언트에 적용됩니다.
:::
:::warning 소스 스캔의 브랜치 선택
@@ -379,7 +383,7 @@ docker-compose -f docker-compose.yml exec worker \
### 두 번째 스캔이 시작되지 않음 — **Scan** 버튼이 비활성
-프로젝트에 이미 `queued` 또는 `running` 스캔이 있습니다. 프로젝트당 활성 스캔은 하나만 허용됩니다. 프로젝트 헤더의 진행중 칩(또는 글로벌 큐의 행)에서 기존 실행을 확인하고 완료를 기다리거나 **Cancel** 한 뒤에 새 스캔을 시작하세요. [프로젝트당 동시 스캔은 하나](#ui에서) 참고.
+프로젝트에 이미 ref 없는(`queued` 또는 `running`) 스캔이 있고, 버튼은 같은 종류를 하나 더 트리거합니다. 브랜치당 활성 스캔은 하나만 허용되며 ref 없는 트리거는 전부 한 묶음으로 셉니다. 프로젝트 헤더의 진행중 칩(또는 글로벌 큐의 행)에서 기존 실행을 확인하고 완료를 기다리거나 **Cancel** 한 뒤에 새 스캔을 시작하세요. 이름 있는 브랜치에서 도는 스캔은 버튼을 막지 않습니다. [브랜치당 동시 스캔은 하나](#ui에서) 참고.
### 완료된 스캔 드로어의 스피너가 멈추지 않음