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
+30 -1
View File
@@ -279,6 +279,7 @@ def assess_root_source_mutation(
locked_issue_number: int | None = None,
role_kind: str | None = None,
mutation_task: str | None = None,
bootstrap_assessment: Any | None = None,
) -> dict[str, Any]:
"""Fail closed for diagnostic/source edits on the control/root checkout.
@@ -286,6 +287,13 @@ def assess_root_source_mutation(
tracked source/test files on the control checkout always block, including
temporary/diagnostic/test-only intent.
#941: ``bootstrap_author_issue_worktree`` is judged by the canonical
``create_issue_bootstrap.bootstrap_permits_control_checkout`` decision over
*bootstrap_assessment* — the same server-derived evidence the #274 and
#604 guards consume — instead of a task-name allowlist local to this
module. Evidence that is absent, malformed, wrongly scoped, or bound to
another workspace leaves the ordinary block in force.
#749: ``create_issue`` is a pure remote mutation with no local tree write.
When *mutation_task* is create_issue and the control checkout has no dirty
source/test files, the missing-worktree signal is suppressed so the
@@ -336,6 +344,19 @@ def assess_root_source_mutation(
if _cib is not None and _cib.is_create_issue_task(mutation_task):
# #749: clean-root create_issue is the sanctioned bootstrap path.
create_issue_bootstrap = True
elif _cib is not None and _cib.bootstrap_permits_control_checkout(
bootstrap_assessment,
task=mutation_task,
workspace_path=workspace,
canonical_repo_root=root,
):
# #941: the author issue-worktree bootstrap is authorized by the
# canonical shared decision over server-derived task-scope
# evidence, never by a task-name allowlist kept in this module.
# The predicate fails closed on missing, malformed, cross-scope,
# dirty, drifted, or wrongly bound evidence, so this arm cannot
# widen the waiver beyond the one sanctioned bootstrap task.
create_issue_bootstrap = True
else:
# Explicit missing-worktree signal for force-on author entrypoints.
reasons.append(
@@ -393,8 +414,15 @@ def assess_production_mutation_guards(
require_author_lock: bool = False,
in_test_mode: bool = False,
mutation_task: str | None = None,
bootstrap_assessment: Any | None = None,
) -> dict[str, Any]:
"""Compose root + scope production guards when they must be active (#683)."""
"""Compose root + scope production guards when they must be active (#683).
#941: *bootstrap_assessment* is the server-derived author-bootstrap
evidence, forwarded unchanged to :func:`assess_root_source_mutation` so
this guard reaches the same canonical decision as the #274 and #604
guards. Omitting it preserves the pre-existing behaviour.
"""
if not production_guards_active(in_test_mode=in_test_mode):
return {
"proven": True,
@@ -414,6 +442,7 @@ def assess_production_mutation_guards(
locked_issue_number=locked_issue_number,
role_kind=role_kind,
mutation_task=mutation_task,
bootstrap_assessment=bootstrap_assessment,
)
if root_assess["block"]:
return {**root_assess, "skipped": False}