fix(scope): wire author bootstrap scope into workflow scope guard (Closes #941)

PR #926 (#892) taught create_issue_bootstrap.bootstrap_permits_control_checkout
to accept task_scope=author_issue_bootstrap and wired that canonical decision
into the #274 branches-only enforcer and the #604 anti-stomp preflight. A third
enforcement path was left unwired.

workflow_scope_guard kept its own copy of the clean-root author decision, gated
on create_issue_bootstrap.is_create_issue_task -- a task-name allowlist that
never contained bootstrap_author_issue_worktree. The real call path

    gitea_bootstrap_author_issue_worktree
      -> verify_preflight_purity
        -> _enforce_issue_scope_guard
          -> workflow_scope_guard.assess_production_mutation_guards

therefore raised ProductionGuardError(missing_issue_worktree) before
assess_author_issue_bootstrap was ever consulted, leaving the #892 deadlock
partially present and blocking issue #931.

Changes:

* workflow_scope_guard.assess_root_source_mutation accepts the server-derived
  bootstrap_assessment and, for the clean-root author case, consults the
  canonical bootstrap_permits_control_checkout decision instead of a local
  task-name allowlist. The create_issue arm is unchanged.
* workflow_scope_guard.assess_production_mutation_guards forwards the evidence.
* _enforce_issue_scope_guard accepts and threads the same assessment the #274
  and #604 guards already consume, so all three judge identical evidence, and
  waives the pre-ownership issue-lock requirement for the bootstrap task via
  that same canonical decision rather than a second task-name allowlist.
* verify_preflight_purity passes the once-computed assessment at both sites.

The waiver cannot widen: the predicate fails closed on missing, malformed,
cross-scope, dirty, drifted, or wrongly bound evidence, so ordinary author
source and test mutation from the control checkout stays forbidden and the
#274/#604/#618/#683 protections are unchanged.

Regression: tests/test_issue_941_scope_guard_bootstrap_wiring.py drives the
real enforcer rather than the authorization helper in isolation, which is why
#892's own predicate tests passed while the live bootstrap stayed blocked.

Closes #941

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
2026-07-26 05:00:09 -05:00
co-authored by Claude Opus 4.8 (1M context) <[email protected]>
parent 8c1d22a658
commit a09c485fc0
3 changed files with 408 additions and 2 deletions
+32 -1
View File
@@ -1546,6 +1546,7 @@ def verify_preflight_purity(
task=task,
target_issue_number=target_issue_number,
require_author_lock=require_author_lock,
bootstrap_assessment=bootstrap_assessment,
)
# #604: common anti-stomp preflight after legacy + #683 enforcers.
_run_anti_stomp_preflight(
@@ -1585,6 +1586,7 @@ def verify_preflight_purity(
task=task,
target_issue_number=target_issue_number,
require_author_lock=require_author_lock,
bootstrap_assessment=bootstrap_assessment,
)
if force_anti_stomp:
_run_anti_stomp_preflight(
@@ -1652,8 +1654,15 @@ def _enforce_issue_scope_guard(
task: str | None = None,
target_issue_number: int | None = None,
require_author_lock: bool = False,
bootstrap_assessment: object = _BOOTSTRAP_UNSET,
) -> None:
"""#683: fail closed on missing/out-of-scope issue ownership for mutations."""
"""#683: fail closed on missing/out-of-scope issue ownership for mutations.
#941: the shared bootstrap assessment is threaded in so this guard judges
the author issue-worktree bootstrap on the same server-derived evidence as
the #274 branches-only and #604 anti-stomp guards. Callers that supply
none fall back to computing it here, which preserves behaviour.
"""
ctx = _resolve_namespace_mutation_context(worktree_path)
workspace = ctx["workspace_path"]
git_state = issue_lock_worktree.read_worktree_git_state(workspace)
@@ -1703,11 +1712,32 @@ def _enforce_issue_scope_guard(
import create_issue_bootstrap as _cib
is_create_issue = _cib.is_create_issue_task(task)
# #941: consume the caller-computed bootstrap assessment when one was
# threaded in, so this guard and the #274/#604 guards judge identical
# evidence. Falling back preserves behaviour for callers that supply none.
bootstrap = (
_create_issue_bootstrap_assessment(task, worktree_path)
if bootstrap_assessment is _BOOTSTRAP_UNSET
else bootstrap_assessment
)
# #941: the author issue-worktree bootstrap is pre-ownership for the same
# reason create_issue is — it exists to break the lock<->worktree cycle, so
# no owning lock can exist yet. The exemption is granted by the canonical
# shared decision over server-derived evidence, never by a task-name list,
# and fails closed on missing, malformed, cross-scope, dirty, drifted, or
# wrongly bound evidence.
bootstrap_waives_ownership = _cib.bootstrap_permits_control_checkout(
bootstrap,
task=task,
workspace_path=workspace,
canonical_repo_root=ctx["canonical_repo_root"],
)
require_lock = bool(require_author_lock) or (
authorish
and workflow_scope_guard.production_guards_forced()
and role == "author"
and not is_create_issue
and not bootstrap_waives_ownership
)
assessment = workflow_scope_guard.assess_production_mutation_guards(
workspace_path=workspace,
@@ -1720,6 +1750,7 @@ def _enforce_issue_scope_guard(
require_author_lock=require_lock,
in_test_mode=_preflight_in_test_mode(),
mutation_task=task,
bootstrap_assessment=bootstrap,
)
workflow_scope_guard.raise_if_blocked(assessment)