Correction (this issue was initially misfiled). It first claimed the merge rejections came from the Claude Code harness ("not any PACT hook"), then from escaped-quote byte-mismatch. Both were wrong — asserted without verifying. After grepping + probing the live hooks, the rejections are from PACT's own merge-guard, and the root cause is a specific, confirmed bug in merge_guard_post.py. Body rewritten to the verified finding.
Problem
A legitimately user-approved squash-merge (PR #1029) was repeatedly rejected by merge_guard_pre.py with "Authorization token exists but does not match this operation" — succeeding only when the command was reduced to a bare form. The merge-guard is a deliberate, valuable security control (prevents API-based bypass of merge approval); this is a false-rejection bug in it, not a request to weaken it.
Root cause (confirmed by probing the live hooks)
The token's pr_number context is minted from the AskUserQuestion question prose:
# merge_guard_post.py:204 (extract_context)
pr_match = re.search(r"#(\d+)|PR\s*(\d+)|pull\s*request\s*(\d+)", question, re.IGNORECASE)
…but read/validated from the anchored gh pr merge <N> command:
# merge_guard_pre.py (_extract_pr_number → _GH_PR_NUMBER_RE)
# anchored to `gh pr merge|close`, with a value-taking-flag guard
These are asymmetric. The mint side grabs the FIRST #NNNN anywhere in the approval text. When that text contains an unrelated issue/PR reference before the real PR — e.g. an umbrella issue (#1028) embedded in the squash commit --subject, or a "related #NNN" — the token is minted for the wrong PR. The read side then correctly extracts the real PR from the command, the two disagree, and _token_matches_command returns False → the genuine, approved merge is rejected.
Empirical evidence (live extract_context, this session)
| AskUserQuestion text (abridged) |
minted pr_number |
command targets |
result |
…gh pr merge 1029 --squash --subject "…(#1028)" --body-file … |
1028 (from #1028) |
1029 |
MISMATCH → rejected |
…squash-merge of PR #1029 … gh pr merge 1029 --squash |
1029 (from #1029) |
1029 |
match → merged |
The bare form only succeeded because #1029 happened to be the first #NNNN in its text. The "use the bare command" workaround is therefore fragile coincidence, not a fix — any approval prose mentioning another #NNNN first re-triggers it.
Proposed fix
Make the mint-side extraction symmetric with the read-side:
- In
extract_context, extract the PR number from the gh pr merge|close <N> command structure embedded in the question (reuse / mirror _extract_pr_number + _GH_PR_NUMBER_RE), not a loose #(\d+) prose match.
- Fallback ordering: prefer the command-anchored positional; fall back to
#NNNN / PR N prose only when no gh pr merge|close <N> is present in the text.
- Regression test: an AskUserQuestion text whose
--subject/prose contains an unrelated #NNNN before the real PR positional MUST mint a token for the real PR (assert pr_number == "1029" for the table's row 1 shape).
Sibling-asymmetry note
The same mint-vs-read asymmetry may exist on the branch and operation_type axes (post-side extract_context uses prose regexes; pre-side uses anchored command detection). Worth an audit pass alongside the PR-number fix — see below.
Also confirmed NOT bugs (by-design, keep)
cd <repo> && gh pr merge … → "Compound destructive command rejected" is correct: the guard intentionally authorizes one destructive op per approval. The fix is to not compound, not to relax the guard.
- The op-type + PR-number token-matching model is sound; only the mint-side PR-number extraction is brittle.
Scope
A real, confirmed PACT code bug in merge_guard_post.py — fully within PACT's control, fixable with a symmetric-extraction change + regression test. (The earlier "platform-gate documentation" framing was wrong and is corrected here.)
Problem
A legitimately user-approved squash-merge (PR #1029) was repeatedly rejected by
merge_guard_pre.pywith "Authorization token exists but does not match this operation" — succeeding only when the command was reduced to a bare form. The merge-guard is a deliberate, valuable security control (prevents API-based bypass of merge approval); this is a false-rejection bug in it, not a request to weaken it.Root cause (confirmed by probing the live hooks)
The token's
pr_numbercontext is minted from the AskUserQuestion question prose:…but read/validated from the anchored
gh pr merge <N>command:These are asymmetric. The mint side grabs the FIRST
#NNNNanywhere in the approval text. When that text contains an unrelated issue/PR reference before the real PR — e.g. an umbrella issue(#1028)embedded in the squash commit--subject, or a "related #NNN" — the token is minted for the wrong PR. The read side then correctly extracts the real PR from the command, the two disagree, and_token_matches_commandreturnsFalse→ the genuine, approved merge is rejected.Empirical evidence (live
extract_context, this session)pr_number…gh pr merge 1029 --squash --subject "…(#1028)" --body-file …#1028)…squash-merge of PR #1029 … gh pr merge 1029 --squash#1029)The bare form only succeeded because
#1029happened to be the first#NNNNin its text. The "use the bare command" workaround is therefore fragile coincidence, not a fix — any approval prose mentioning another#NNNNfirst re-triggers it.Proposed fix
Make the mint-side extraction symmetric with the read-side:
extract_context, extract the PR number from thegh pr merge|close <N>command structure embedded in the question (reuse / mirror_extract_pr_number+_GH_PR_NUMBER_RE), not a loose#(\d+)prose match.#NNNN/PR Nprose only when nogh pr merge|close <N>is present in the text.--subject/prose contains an unrelated#NNNNbefore the real PR positional MUST mint a token for the real PR (assertpr_number == "1029"for the table's row 1 shape).Sibling-asymmetry note
The same mint-vs-read asymmetry may exist on the branch and operation_type axes (post-side
extract_contextuses prose regexes; pre-side uses anchored command detection). Worth an audit pass alongside the PR-number fix — see below.Also confirmed NOT bugs (by-design, keep)
cd <repo> && gh pr merge …→ "Compound destructive command rejected" is correct: the guard intentionally authorizes one destructive op per approval. The fix is to not compound, not to relax the guard.Scope
A real, confirmed PACT code bug in
merge_guard_post.py— fully within PACT's control, fixable with a symmetric-extraction change + regression test. (The earlier "platform-gate documentation" framing was wrong and is corrected here.)