Skip to content

merge-guard: raise Layer-1 retirement granularity to op+target+flags (v4.4.55)#1110

Merged
michael-wojcik merged 4 commits into
Synaptic-Labs-AI:mainfrom
michael-wojcik:fix-1100-retirement-flag-granularity
Jul 5, 2026
Merged

merge-guard: raise Layer-1 retirement granularity to op+target+flags (v4.4.55)#1110
michael-wojcik merged 4 commits into
Synaptic-Labs-AI:mainfrom
michael-wojcik:fix-1100-retirement-flag-granularity

Conversation

@michael-wojcik

Copy link
Copy Markdown
Collaborator

Summary

Raises merge-guard Layer-1 token retirement granularity from op+target to op+target+bound_flags (#1100), curing a bounded re-approval friction: a successful bare same-op/same-target command (e.g. gh pr close 5) no longer retires an operator's approved escalated flag-carrying token (e.g. {close, 5, [--delete-branch]}). Follow-on to #1097 (target-aware retirement).

What changed

Confined to _retire_token_for_command in pact-plugin/hooks/merge_guard_post.py — a PostToolUse observer (fires after a successful execution; never gates a command). Two independent, both-safe moves compose:

  • The flag-inequality skip is purely subtractive (if set(ctx.bound_flags) != cmd_flags: continue) — fewer tokens on the flag axis; byte-mirrors the read arm's existing security: --admin and other privileged flags ride past merge-guard auth binding #1042 set-equality (merge_guard_pre.py:559).
  • The cmd_target derivation switches to leg-isolated extract_command_context (mirroring the read arm at merge_guard_pre.py:536-540), so target + flags are derived from one SSOT call — mint, read, and retirement agree on (op, target, bound_flags) identity by construction.

Reuse-only: the mint already stores bound_flags (SSOT merge_guard_common.py:1400); the read arm already set-compares it. No mint / read / shared-extractor edits.

Safety — dual-independent certification

Certified SAFE-TO-MERGE by two independent adversarial streams — a pact-security-engineer review (8-item verification table) and a 3-lens adversarial break-sweep — which converged on the same conclusion and the same single (now-fixed) doc-precision nit:

  • No laundering. The authorization arm (_token_matches_command / check_merge_authorization) is untouched; it binds (op, target, bound_flags) exactly and consumes on match. Any surviving under-retired token can replay only as its identical approved command — MAX_USES(2) + TTL(300s) bounded — never a different destructive command.
  • Over-block structurally impossible. The observer discards its return value and sys.exit(0); it never emits a permissionDecision. The cardinal-sin direction is out of reach by construction.
  • Self-consume preserved (including the flagless [] == [] case); extraction-symmetric (derives flags via the extract_privileged_flags SSOT, never _normalized_flags); target-less narrowing is inert (a target-less token fails the read arm's positive per-op-class target match → authorizes nothing, and no production mint ever creates one).

This aligns with the merge-guard design principle: a faithful single-command click always mints and merges; over-block is wrong by definition. Retirement is an observer that only ever removes tokens — it never grants authorization — so this change is laundering-safe by construction.

Tests

New TestFlagAwareRetirement: matching-flag self-consume (incl. flagless), friction-cured survival (both directions), flag-normalization equivalence (-d==--delete-branch, --repo=/-R/--repo , reorder, dup, value-mismatch), a seam-independent black-box flip + an SSOT-neuter non-vacuity proof, and a both-modes session matrix. Plus a post→pre seam integration test, and an intent-preserving fix to the pre-existing #1097 target-axis non-vacuity probe (token made flagless so it isolates the target predicate under the new flag axis; target non-vacuity still proven).

Full suite: 10620 passed, 11 skipped, 0 failed, 0 errors.

Version

PATCH bump 4.4.54 → 4.4.55 — a bounded friction refinement; no user-visible behavioral surface change.


Completes ACs of #1100; closure pending post-install live-probe.

…lags (Synaptic-Labs-AI#1100)

_retire_token_for_command now discriminates on bound_flags in addition to
op+target+session: a successful bare same-op/same-target command no longer
retires an operator's approved escalated (flag-carrying) token — the Synaptic-Labs-AI#1100
over-retirement friction. Target and the flag set are derived from ONE
leg-isolated extract_command_context call, mirroring the read arm, so mint,
read, and retirement agree on (op, target, bound_flags) identity by
construction (same _target_value + same extract_privileged_flags SSOT). The
discriminator is conjunctive and monotonic (retires fewer tokens, never more),
so the only residual is a tolerated under-retire backstopped by TTL + MAX_USES;
as a PostToolUse observer it can never over-block a faithful click.

Tests: new TestFlagAwareRetirement (self-consume incl. flagless, friction-cured
survival both directions, flag-normalization equivalence, seam-independent and
SSOT-neuter non-vacuity, both-modes session matrix) plus a post->pre seam test.
Update the pre-existing target-axis non-vacuity probe to a flagless token so it
isolates the target predicate under the new flag axis.
…retirement docstring

Both the independent security review and an adversarial break-sweep flagged that
the prior "conjunctive and MONOTONIC — retires fewer, never more" wording
understated the change. It composes two independent, both-safe moves: a purely
subtractive flag-inequality skip, plus a leg-isolated cmd_target derivation that
purifies the compare toward the executed op's own identity (mirroring the read
arm) and can retire a self-consume a prior whole-command mis-scan would have
missed — the safe direction, since over-retire only consumes a token, never
authorizes one. Docstring-only; behavior unchanged.
Copilot AI review requested due to automatic review settings July 5, 2026 06:59

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR refines merge-guard’s Layer-1 retirement behavior so that a successful command only retires an authorization token when (op_type, target, bound_flags, session) match—preventing a bare same-op/same-target command from burning a previously approved flag-escalated token for that same target.

Changes:

  • Updated merge_guard_post._retire_token_for_command to derive command context via leg-isolated extract_command_context and to require set-equality on bound_flags before retiring a token.
  • Added/expanded tests validating self-consume retirement, survival on flag mismatch, normalization equivalence, and an end-to-end post→pre seam scenario.
  • Bumped documented/plugin version strings from 4.4.54 → 4.4.55.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
pact-plugin/hooks/merge_guard_post.py Tightens token retirement matching to include privileged-flag identity and uses leg-isolated context extraction.
pact-plugin/tests/test_merge_guard.py Adds comprehensive flag-aware retirement test matrix and adjusts a target-axis non-vacuity test to remain isolated under the new flag axis.
pact-plugin/tests/test_merge_guard_seam_integration.py Adds an end-to-end disk-seam test ensuring escalated tokens survive bare retirement and still authorize.
README.md Updates cache-path version example to 4.4.55.
pact-plugin/README.md Updates displayed plugin version to 4.4.55.
pact-plugin/.claude-plugin/plugin.json Bumps plugin version to 4.4.55.
.claude-plugin/marketplace.json Bumps marketplace plugin version to 4.4.55.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread pact-plugin/hooks/merge_guard_post.py Outdated
Comment on lines +772 to +773
if set(ctx.get("bound_flags", [])) != cmd_flags:
continue
…nd_flags (Copilot Synaptic-Labs-AI#1110 review)

The Synaptic-Labs-AI#1100 flag-axis read `set(ctx.get("bound_flags", []))` could crash the
PostToolUse retirement observer on a malformed/legacy token: a JSON-null
bound_flags yields None (dict.get returns the present key's value, not the
default) so set(None) raises TypeError; a list containing a non-string/unhashable
element makes set() raise on the unhashable. An unguarded crash aborts the whole
scan, skipping retirement for the rest of the run.

Add an isinstance-list + all-string-element guard mirroring the function's
existing malformed-ctx skip: a malformed token is skipped gracefully (the safe
under-retire direction) and never poisons the loop — a valid token in the same
run still retires. Skip, not delete (deletion is the read arm's reaper job).
Valid-token semantics unchanged; empty [] still passes.

Tests: TestMalformedTokenRetirementRobustness — malformed bound_flags
(null/int/float/bool/string and list-with-unhashable-element) never raises and is
skipped; a malformed token forced first does not poison a valid retirement.
@michael-wojcik michael-wojcik merged commit fa3e876 into Synaptic-Labs-AI:main Jul 5, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants