safety(merge-frontier): bind the real merge adapter to a ready merge_actuation verdict + D20-lint carve + D-decision, before any merge call lands #171

Closed
opened 2026-06-24 01:49:13 +02:00 by claude · 1 comment
Collaborator

Context — the decision layer just landed, the actuator is next

#170 shipped merge_actuation.py as a read-only preflight: it emits controller_may_merge + allowed_action: "merge_pull_request" as a verdict (reusing the full controller_approval_actuation_blockers stack — no-self-approval, hard-manual, head-bound, zero-blocker — plus visible_actor and live-state), but it does not call Forgejo merge. The D20 lint is intact: merge_pull_request( (call) and raw /merge URLs are still build-time-banned. 👏

This is the merge-frontier equivalent of the read-only contract chain. The next PR — the adapter that performs an actual Forgejo merge — is the single most dangerous change in the repo's history. This issue pre-states its contract (the merge counterpart of #127, which did this for the approval actuator) so it can't land decoupled or unguarded.

This is operator-gated by design — it needs a D-decision first

Crossing the build-time merge ban is not an autonomous step. Before the merge adapter is built it needs an explicit operator decision (a D29-style record), analogous to D24/D26 for approval, specifying:

  • whether to authorize a real merge actuator now (vs. stay verdict-only and let an external controller merge);
  • scope: dry-run-first? which PR classes? trust-tier conservative only? pdurlej/platform before openclaw?
  • identity: does the merge run under an external controller (ClawSweeper/OpenClaw) per D26 steady-state, or transitionally under patchwarden (a deliberate, recorded choice like D26's visible_actor)?

Definition-of-done for the merge-adapter PR (inspiration, not spec)

  1. Structural binding: the adapter consumes a ready merge_actuation verdict (#170) as a required precondition input — unknown / blocked / needs_live_state / missing → no merge (fail-closed). It must not re-derive eligibility ad-hoc.
  2. D20-lint carve, not weakening: update test_d20_architectural_boundary to permit merge_pull_request( only inside the one designated merge adapter (exactly how the lint already confines ensure_pr_approval/create_pr_review to contract_publish.py), and keep banning it — and raw /merge URLs — everywhere else. The carve must be a named single call site, never a blanket lift.
  3. Drift-guard (sibling to the approval guard): prove the merge call is unreachable without a ready merge_actuation verdict; assert the verdict carried no-self-approval, non-hard-manual, head-bound, and visible-actor checks.
  4. Live-head at actuation: re-fetch live PR head immediately before merge; head != verdict.target_sha or fetch-fail → refuse (don't merge a moved head).
  5. Fail-closed everywhere; opt-in flag (default off), like --approve-on-pass.

Why now

#170 (preflight) makes the actuator imminent. Pre-stating the contract is cheaper than catching a decoupled merge in review after the live merge surface exists. Refs #170, #127 (approval precedent), #121/#129 (the guard stack), D20/D24/D26. — claude (architect loop)

## Context — the decision layer just landed, the actuator is next #170 shipped `merge_actuation.py` as a **read-only preflight**: it emits `controller_may_merge` + `allowed_action: "merge_pull_request"` as a *verdict* (reusing the full `controller_approval_actuation_blockers` stack — no-self-approval, hard-manual, head-bound, zero-blocker — plus visible_actor and live-state), but it **does not call Forgejo merge**. The D20 lint is intact: `merge_pull_request(` (call) and raw `/merge` URLs are still build-time-banned. 👏 This is the merge-frontier equivalent of the read-only contract chain. The **next** PR — the adapter that performs an *actual* Forgejo merge — is the single most dangerous change in the repo's history. This issue pre-states its contract (the merge counterpart of #127, which did this for the approval actuator) so it can't land decoupled or unguarded. ## This is operator-gated by design — it needs a D-decision first Crossing the build-time merge ban is not an autonomous step. Before the merge adapter is built it needs an explicit operator decision (a **D29**-style record), analogous to D24/D26 for approval, specifying: - **whether** to authorize a real merge actuator now (vs. stay verdict-only and let an external controller merge); - **scope**: dry-run-first? which PR classes? trust-tier `conservative` only? `pdurlej/platform` before `openclaw`? - **identity**: does the merge run under an external controller (ClawSweeper/OpenClaw) per D26 steady-state, or transitionally under `patchwarden` (a deliberate, recorded choice like D26's `visible_actor`)? ## Definition-of-done for the merge-adapter PR (inspiration, not spec) 1. **Structural binding**: the adapter consumes a **ready** `merge_actuation` verdict (#170) as a *required precondition input* — unknown / blocked / `needs_live_state` / missing → no merge (fail-closed). It must not re-derive eligibility ad-hoc. 2. **D20-lint carve, not weakening**: update `test_d20_architectural_boundary` to permit `merge_pull_request(` **only** inside the one designated merge adapter (exactly how the lint already confines `ensure_pr_approval`/`create_pr_review` to `contract_publish.py`), and keep banning it — and raw `/merge` URLs — everywhere else. The carve must be a *named single call site*, never a blanket lift. 3. **Drift-guard** (sibling to the approval guard): prove the merge call is **unreachable** without a ready `merge_actuation` verdict; assert the verdict carried no-self-approval, non-hard-manual, head-bound, and visible-actor checks. 4. **Live-head at actuation**: re-fetch live PR head immediately before merge; `head != verdict.target_sha` or fetch-fail → refuse (don't merge a moved head). 5. **Fail-closed everywhere**; opt-in flag (default off), like `--approve-on-pass`. ## Why now #170 (preflight) makes the actuator imminent. Pre-stating the contract is cheaper than catching a decoupled merge in review after the live merge surface exists. Refs #170, #127 (approval precedent), #121/#129 (the guard stack), D20/D24/D26. — claude (architect loop)
Author
Collaborator

Architect update (loop) — the build is converging on "external actuator", which may make the D20-lint carve moot

Since filing this, codex shipped the full read-only authorization chain: merge_actuation preflight (#170) → controller_bundle (#172) → merge_frontier_decision operator-gate (#173, fail-closed to operator_required) → dry-run rehearsal/receipt (#181/#182) → controller_live_authorization preflight (#183) → live receipts wired into action-receipt/loop-state (#184/#185).

Crucially, controller_live_authorization emits live_controller_may_execute as a verdict — it does not merge. It requires an operator live_authorized merge-frontier decision + a dry-run receipt + patchwarden_write_allowed=false / external_write_allowed=false. So the design is settling on: Patchwarden stays verdict-only permanently; the actual Forgejo merge is performed by the external controller (ClawSweeper/OpenClaw) under its own identity (D26).

Implication for this issue's DoD item #2 (the D20-lint carve): if Patchwarden never grows a merge adapter (the actuator is wholly external), then the lint's merge_pull_request( ban should stay permanent — never carved. The "merge adapter" framing here was the in-Patchwarden path; the architecture is choosing the safer external-actuator path instead. The remaining DoD items still apply, but to the external controller's consumption of the live-authorization verdict, not to a Patchwarden call site:

  • bind the external merge to a ready controller_live_authorization verdict (not re-derive),
  • live-head recheck at actuation,
  • no-self-approval / non-hard-manual / head-bound carried by the verdict,
  • dry-run-first (already enforced by #183 requiring a dry-run receipt).

So the operator's "authorize the merge actuator" decision is really "wire the external controller" + the D26 identity choice — not "build/carve a Patchwarden merge call." Keeping this issue open as the contract the external actuator must honor; the D20-lint stays as-is (permanent ban). — claude (architect loop)

## Architect update (loop) — the build is converging on "external actuator", which may make the D20-lint carve moot Since filing this, codex shipped the full read-only authorization chain: `merge_actuation` preflight (#170) → `controller_bundle` (#172) → `merge_frontier_decision` operator-gate (#173, fail-closed to `operator_required`) → dry-run rehearsal/receipt (#181/#182) → `controller_live_authorization` preflight (#183) → live receipts wired into action-receipt/loop-state (#184/#185). Crucially, **`controller_live_authorization` emits `live_controller_may_execute` as a verdict — it does not merge.** It requires an operator `live_authorized` merge-frontier decision **+** a dry-run receipt **+** `patchwarden_write_allowed=false` / `external_write_allowed=false`. So the design is settling on: **Patchwarden stays verdict-only permanently; the actual Forgejo merge is performed by the external controller (ClawSweeper/OpenClaw) under its own identity (D26).** **Implication for this issue's DoD item #2 (the D20-lint carve):** if Patchwarden never grows a merge adapter (the actuator is wholly external), then the lint's `merge_pull_request(` ban should stay **permanent — never carved.** The "merge adapter" framing here was the in-Patchwarden path; the architecture is choosing the safer external-actuator path instead. The remaining DoD items still apply, but to the **external controller's** consumption of the live-authorization verdict, not to a Patchwarden call site: - bind the external merge to a ready `controller_live_authorization` verdict (not re-derive), - live-head recheck at actuation, - no-self-approval / non-hard-manual / head-bound carried by the verdict, - dry-run-first (already enforced by #183 requiring a dry-run receipt). So the operator's "authorize the merge actuator" decision is really "**wire the external controller**" + the D26 identity choice — not "build/carve a Patchwarden merge call." Keeping this issue open as the contract the external actuator must honor; the D20-lint stays as-is (permanent ban). — claude (architect loop)
Sign in to join this conversation.
No labels
agent/claude-code
agent/codex
agent/gemini
agent/hermes
agent/iskra
agent/ollama
agent/patchwarden
area:business-model
area:competitive
area:discovery
area:forgejo
area:metrics
area:product-strategy
area:v0-core
cagan-grade-approved
client:platform
dependency/blocked
dependency/blocks-others
dependency/cross-repo
dependency/needs-confirmation
domain:agents
domain:ci
domain:docs
domain:forgejo
domain:infra
domain:memory
domain:runtime
domain:signal
domain:ux
flow/architecture
flow/blocked
flow/deployed
flow/done
flow/implementation
flow/intake
flow/maintained
flow/observed
flow/ready
flow/refining
flow/retired
flow/review
judge/codex-candidate
judge/hermes-candidate
judge/low-confidence
judge/needs-refinement
judge/operator-needed
judge/p0
judge/p1
judge/p2
judge/p3
judge/park
judge/patchwarden-candidate
judge/stale-priority
kind/adr
kind/bug
kind/chore
kind/feature
kind/infra
kind/ops
kind/refactor
kind/research
kind:artifact
kind:decision
kind:dogfood
kind:epic
kind:implementation
kind:research
merge/auto
merge/manual
merge/manual-dependency-conflict
merge/manual-failing-tests
merge/manual-merge-conflict
merge/manual-missing-review
merge/manual-operator-preference
merge/manual-red-zone
merge/manual-security-sensitive
merge/manual-unclear-scope
merge/manual-unknown
mode:operator-only
mode:patchwarden-iskra-approved
mode:safe-auto
observed/erroring
observed/needs-followup
observed/pending
observed/retire-candidate
observed/unused
observed/used
priority:p0
priority:p1
priority:p2
priority:p3
ready-for-agent
review:claude-reviewed
review:codex-reviewed
review:dziadek-reviewed
review:needs-human
safety:external-write
safety:no-prod-mutation
safety:prod-impact
safety:secret-touch
size/large
size/medium
size/small
size/tiny
size/unknown
source/adr
source/agent-generated
source/manual
source/operator-chat
source/voice-note
status:blocked
status:blocked-on-discovery
status:cagan-grade-review-pending
status:codex-ready
status:merged:pending-evidence
status:needs-evidence
status:needs-operator-decision
status:operator-needed
status:parked
tier:0-anchor
tier:0-platform-substrate
tier:1-core
tier:1-iskra-value-layer
tier:2-supporting
tier:2-tools-products-modules
type:bug
type:chore
type:docs
type:feat
type:policy
type:research
wave:1-foundation
wave:2-positioning
wave:3-validation
wave:4-economics
wave:5-operating
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
pdurlej/patchwarden#171
No description provided.