Skip to content

Commit 51e6c5a

Browse files
fix(merge-guard): unify branch_set + mass_target onto injective netstring _canonical_join SSOT (issue 1136) (#1145)
* fix(merge-guard): unify branch_set + mass_target onto injective netstring _canonical_join SSOT (issue 1136) #1136 is a delimiter-class canonical-identity collision, not a single-delimiter bug: mass_target's identity was non-injective at two git-legal delimiters (the refspec comma AND the remote/refspec boundary), letting two distinct destructive sets share one identity (a cross-set under-block). Replace the hand-rolled delimiter joins in both destructive-set identity encoders with one pure netstring/length-prefix primitive, `_canonical_join(items)` = the `len:value` framing, injective by construction and content-agnostic: - branch_set = _canonical_join(sorted(set(names))) - mass_target = _canonical_join([flags_part, remote, *sorted(set(refspecs))]) The structural delimiters are eliminated (subsumed by framing), closing the whole delimiter class at once. The \x00implicit sentinel stays unchanged (its NUL keeps it disjoint from a real remote literally named "implicit" under framing). Refspecs are now deduped (a duplicate ref is the same destructive target). Over-block is closed by construction: mint and read derive the identity from the same _canonical_join via the one extract_command_context SSOT, so a faithful click self-matches. No mint/read call site is split. Format-pinned tests updated to be format-following (assert against _canonical_join calls, not hardcoded literals); format-agnostic distinctness/reorder/absence asserts left intact. Doc-drift comment in merge_guard_pre corrected; stale encoding prose in the shared-hook docstrings brought in line with the framing. * chore(release): bump plugin version to 4.5.7 Canonical-identity SSOT fix (issue 1136) — merge_guard mass_target + branch_set delimiter-class collision closed via the shared injective netstring _canonical_join. * test(merge-guard): #1136 canonical-join SSOT SACROSANCT cert — injectivity proof + closure + over-block corpus 52-row bidirectional certification for the canonical-identity SSOT (issue 1136): - Injectivity by exhibited inverse: a total constructive left-inverse decode round-trips every edge (empty list/item, embedded colon/digits/NUL, multi-digit lengths, netstring-looking content) plus a 5000-random property test — bounds the shared-helper concentration hazard by construction, not by sampling. - Closure: base (9256c93, baked in-test via git-show+exec) COLLIDES -> HEAD REFUSES for BOTH the comma AND the boundary witnesses, at the identity level AND the real mint->execute gate (a 2-set token no longer authorizes the 3-set). - Over-block primary corpus (the cardinal direction): faithful mass/branch deletes mint and self-match; reorder and dedup still match. - Doubled surface base-vs-HEAD, sentinel isolation, the dedup behavior row, and the colon force-delete edge. Full suite: 11114 passed / 11 skipped / 0 failed / 0 errors (python 3.12.7). * test(merge-guard): harden #1136 cert non-vacuity — self-contained base-through-gate row + HASH_3 control Self-critical cert review strengthenings on #1136's core claim (both were empirically confirmed, not fixing a hole — the pre-registered vacuity suspects were falsified): - Add `hash-in-remote` (the HASH_3 form `git push "origin#a" --delete b c`) to the over-block self-match corpus — the missing mintable + self-authorizing control that proves the # cross-set DENY is a WRONG-SET denial, not an unmintable-always- deny artifact. - New test_base_encoder_through_real_gate_cross_authorizes: patches the baked base encoder into the one SSOT and asserts the collision CROSS-AUTHORIZES at the real mint->execute gate for BOTH witnesses — making the closure non-vacuity SELF- CONTAINED (no longer relying on an out-of-file monkeypatch). Cert 52 -> 54 rows. Full suite 11116 passed / 11 skipped / 0 failed / 0 errors. * ci(merge-guard): make baked-base cert files CI-portable under shallow clone The #1136 and (pre-existing) #1129-R2 certification files load a base classifier via `git show <base-sha>:...` at module load. CI's actions/checkout does a shallow clone (fetch-depth 1) lacking those base commits, so `git show` failed and BOTH files aborted collection — taking the whole suite down (0 tests run). Surfaced by CI + the Copilot review. Two complementary fixes (defense-in-depth), modeled on the existing test_merge_guard_1118_recert.py precedent: - .github/workflows/tests.yml: fetch-depth: 0 on checkout, so CI has full history and every baked-base non-vacuity differential actually RUNS in CI. - Both cert files: _load_classifier returns None on git-show failure (collection succeeds), the module-level base symbols are None-safe, and a @requires_history skip gates ONLY the base-vs-HEAD differential rows — the HEAD-side closure/REFUSE assertions and absolute invariants stay unguarded so a fix-revert is still caught even under a shallow clone. No behavioral assertion altered (R2 cert: guards only). Full suite 11119 passed / 11 skipped / 0 failed / 0 errors.
1 parent 9256c93 commit 51e6c5a

12 files changed

Lines changed: 498 additions & 80 deletions

.claude-plugin/marketplace.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
"name": "PACT",
1313
"source": "./pact-plugin",
1414
"description": "Orchestration harness that turns Claude Code into a coordinated team of specialist AI agents",
15-
"version": "4.5.6",
15+
"version": "4.5.7",
1616
"author": {
1717
"name": "Synaptic-Labs-AI"
1818
},

.github/workflows/tests.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,13 @@ jobs:
8181
# These majors run on Node24 (checkout@v4 / setup-python@v5 were Node20,
8282
# deprecated by GitHub — forced off by 2026-06-16, so re-pinned forward).
8383
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
84+
with:
85+
# Full history so the baked-base cert files can `git show <base-sha>`
86+
# (their base-vs-HEAD non-vacuity differentials). The default shallow
87+
# clone (fetch-depth: 1) lacks the base commits -> `git show` fails ->
88+
# those files abort collection (0 tests run). Full history makes every
89+
# differential RUN in CI; the in-file self-skip guards are the fallback.
90+
fetch-depth: 0
8491

8592
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
8693
with:

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -605,7 +605,7 @@ When installed as a plugin, PACT lives in your plugin cache:
605605
│ └── cache/
606606
│ └── pact-plugin/
607607
│ └── PACT/
608-
│ └── 4.5.6/ # Plugin version
608+
│ └── 4.5.7/ # Plugin version
609609
│ ├── agents/
610610
│ ├── commands/
611611
│ ├── skills/

pact-plugin/.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "PACT",
3-
"version": "4.5.6",
3+
"version": "4.5.7",
44
"description": "Orchestration harness that turns Claude Code into a coordinated team of specialist AI agents",
55
"author": {
66
"name": "Synaptic-Labs-AI",

pact-plugin/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# PACT — Orchestration Harness for Claude Code
22

3-
> **Version**: 4.5.6
3+
> **Version**: 4.5.7
44
55
Turn a single Claude Code session into a managed team of specialist AI agents that prepare, design, build, and test your code systematically.
66

pact-plugin/hooks/merge_guard_pre.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -556,7 +556,7 @@ def _token_matches_command(token: dict, command: str) -> bool:
556556
# SSOT), so a scalar token can NOT authorize a set command or vice-versa
557557
# (the absent-key side fails _both_present_equal), and a {a,b} token can
558558
# NOT authorize {a,b,c} (unequal canonical strings) while a {b,a} reorder
559-
# MATCHES (both canonicalize to `a,b`). The #1032 under-block is closed by
559+
# MATCHES (both canonicalize to the SAME string). The #1032 under-block is closed by
560560
# equality; both sides derive via the ONE extract_command_context SSOT so
561561
# mint and read cannot drift.
562562
return (

pact-plugin/hooks/shared/merge_guard_common.py

Lines changed: 46 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,7 @@
168168
import re
169169
import shlex
170170
import time
171+
from collections.abc import Sequence
171172
from pathlib import Path
172173

173174
from .paths import get_claude_config_dir
@@ -875,32 +876,44 @@ def _extract_branch_name(command: str) -> str | None:
875876
return _strip_surrounding_quotes(positionals[0])
876877

877878

879+
def _canonical_join(items: Sequence[str]) -> str:
880+
"""Netstring/length-prefix encode a sequence of strings into ONE canonical
881+
identity STRING, injective by construction (a left-inverse decoder exists), so
882+
distinct sequences never collide REGARDLESS of item content — incl. commas, '@',
883+
'#', ':' and NUL. Pure + deterministic (no sort, no dedup — the caller supplies a
884+
canonical ordered list). Stays a `str` (the token persists as JSON; compared by
885+
str()-equality, never decoded). Shared SSOT for branch_set + mass_target (#1136)."""
886+
return "".join(f"{len(i)}:{i}" for i in items)
887+
888+
878889
def _extract_branch_delete_set(command: str) -> str | None:
879890
"""Extract the canonical MULTI-branch identity of a force-delete command.
880891
881892
The MULTI-target sibling of _extract_branch_name (which owns the SINGLE
882893
branch). Handles the CLI `git branch -D|--delete --force <a> <b> ...` form
883894
with TWO OR MORE positional branch names, returning a canonical
884-
sort+dedup+quote-strip identity STRING joined on NUL (`\x00`), or None
895+
sort+dedup+quote-strip identity STRING (the shared netstring `_canonical_join`
896+
SSOT), or None
885897
when fewer than two branch names are positively extractable (<2 -> defers to
886898
the scalar _extract_branch_name path — the BOUNDARY discriminator, so a
887899
command populates EXACTLY ONE of `branch` / `branch_set`).
888900
889-
Mirrors the _extract_mass_delete_target precedent (#1062b): an
901+
Shares the _extract_mass_delete_target encoder (#1062b/#1136): an
890902
ORDER-INDEPENDENT identity STRING (never a tuple/list — a token persists as
891903
JSON, and the read side compares via `str()`, so a list<->tuple round-trip
892904
must never enter the identity), built + canonicalized in this ONE shared SSOT
893905
so mint and read derive byte-identical strings (D2 symmetry by construction).
894-
The join separator is NUL (`\x00`): git forbids control bytes in branch
895-
names, so no name can contain it and the joined identity is INJECTIVE —
896-
distinct branch SETS never collide. (A bare `,` was NOT injective: a branch
897-
literally named `a,b` collided with the set {a, b}, cross-authorizing distinct
898-
sets — the F1 review finding. NUL is JSON/str()-safe: json escapes it on dump,
899-
restores it on load, and str() is stable, so the round-trip holds.)
906+
Encoding is the netstring `_canonical_join` (`len:name` framing): the identity
907+
is INJECTIVE by construction and CONTENT-AGNOSTIC, so distinct branch SETS never
908+
collide regardless of name content — no separator has to be ref-illegal. (A bare
909+
`,` join was NOT injective: a branch literally named `a,b` collided with the set
910+
{a, b}, cross-authorizing distinct sets — the F1 review finding; framing closes
911+
that class outright. The string is JSON/str()-safe: json round-trips it and str()
912+
is stable, so the identity survives token persistence.)
900913
Set-EQUALITY on this INJECTIVE string then closes the #1032 multi-target
901914
under-block UNCONDITIONALLY: a `{a,b}` token cannot authorize `{a,b,c}`
902915
(unequal strings) while a `{b,a}` reorder MATCHES (both canonicalize to the
903-
same NUL-joined string).
916+
same string).
904917
905918
Uses the SAME tokenization as _extract_branch_name (executable-prefix
906919
truncation at a benign continuation/redirect via _executable_prefix, then drop
@@ -921,12 +934,13 @@ def _extract_branch_delete_set(command: str) -> str | None:
921934
if len(positionals) < 2:
922935
return None
923936
names = sorted({_strip_surrounding_quotes(t) for t in positionals})
924-
# Join on NUL (\x00) — a REF-ILLEGAL separator (git forbids control bytes in
925-
# branch names) so no name can contain it -> the identity is INJECTIVE and
926-
# distinct sets never collide (the F1 fix; a bare `,` collided a branch named
927-
# `a,b` with the set {a, b}). JSON/str()-safe, so D2 mint==read symmetry and
928-
# the JSON token round-trip both hold.
929-
return "\x00".join(names) or None
937+
# Encode via the shared netstring SSOT (_canonical_join): each name is framed as
938+
# `len:name`, so the identity is INJECTIVE by construction and CONTENT-AGNOSTIC —
939+
# distinct sets never collide regardless of name content, without depending on any
940+
# separator being ref-illegal (an earlier bare `,` join collided a branch named
941+
# `a,b` with the set {a, b}). JSON/str()-safe, so mint==read symmetry and the JSON
942+
# token round-trip both hold.
943+
return _canonical_join(names) or None
930944

931945

932946
def _extract_force_push_target_ref(command: str) -> str | None:
@@ -1102,14 +1116,15 @@ def _extract_mass_delete_target(command: str) -> str | None:
11021116
benign `-o ci.skip` does not over-bind; privileged flags ride the existing #1042
11031117
`bound_flags` axis. Derived identically on BOTH arms via this ONE SSOT → mint==read
11041118
parity by construction; recognition⟺mintability (the arm returns the op IFF this is
1105-
non-None) → #1064-impossible. Returns a READABLE tuple, never a hash:
1119+
non-None) → #1064-impossible. Returns a READABLE netstring identity STRING
1120+
(`len:value`-framed field tuple; no `@`/`#`/`,` delimiters; #1136), never a hash:
11061121
1107-
<sorted-mass-flags>@<remote-or-implicit-marker>[#<sorted-refspecs>]
1108-
git push --mirror origin → --mirror@origin
1109-
git push --mirror → --mirror@\\x00implicit (implicit-remote MINTABLE)
1110-
git push --prune origin refs/heads/main → --prune@origin#refs/heads/main
1111-
git push origin --delete a b → --delete@origin#a,b (binds the EXACT deleted set)
1112-
git push origin :a :b → --delete@origin#a,b
1122+
_canonical_join([<sorted-mass-flags>, <remote-or-implicit-marker>, *<sorted-deduped-refspecs>])
1123+
git push --mirror origin → 8:--mirror6:origin
1124+
git push --mirror → 8:--mirror9:\\x00implicit (implicit-remote MINTABLE)
1125+
git push --prune origin refs/heads/main → 7:--prune6:origin15:refs/heads/main
1126+
git push origin --delete a b → 8:--delete6:origin1:a1:b (binds the EXACT deleted set)
1127+
git push origin :a :b → 8:--delete6:origin1:a1:b
11131128
11141129
BOUNDARY (lead-mandated, no double-classification): a SINGLE-ref delete is owned by
11151130
`_extract_remote_ref_delete_target` (tried FIRST in the recognition arm). This
@@ -1162,10 +1177,11 @@ def _extract_mass_delete_target(command: str) -> str | None:
11621177
else:
11631178
return None
11641179

1165-
target = f"{flags_part}@{remote}"
1166-
if refspecs:
1167-
target += "#" + ",".join(refspecs)
1168-
return target
1180+
# Encode the destructive identity via the shared netstring SSOT (_canonical_join):
1181+
# framing subsumes the old `@`/`#`/`,` delimiters, so the identity is injective for
1182+
# ANY field content (closes the #1136 `#`/comma collision class). Dedup refspecs
1183+
# (a duplicate ref is the same target); empty refspecs frame uniformly (no `#`).
1184+
return _canonical_join([flags_part, remote, *sorted(set(refspecs))])
11691185

11701186

11711187
# -----------------------------------------------------------------------------
@@ -1427,11 +1443,11 @@ def extract_command_context(command: str, flag_scan_text: str | None = None) ->
14271443
pr_number: str (merge / close)
14281444
branch: str (branch-delete — SINGLE target, exactly 1 positional)
14291445
branch_set: str (branch-delete — MULTI target #1129, >=2 positionals) —
1430-
canonical sort+dedup+quote-strip names joined on NUL (`\x00`,
1431-
a ref-illegal separator → injective, no delimiter collision)
1446+
canonical sort+dedup+quote-strip names via the shared netstring _canonical_join (`len:name` framing,
1447+
injective by construction, content-agnostic — no delimiter collision)
14321448
target_ref: str (force-push / push-to-main, KD-6; remote-ref-delete #1062a)
1433-
mass_target: str (remote-mass-delete #1062b) — normalized identity tuple
1434-
<sorted-mass-flags>@<remote-or-implicit-marker>[#<sorted-refspecs>]
1449+
mass_target: str (remote-mass-delete #1062b) — normalized identity STRING
1450+
_canonical_join([<sorted-mass-flags>, <remote-or-implicit-marker>, *<sorted-deduped-refspecs>])
14351451
protected_branch: str (branch-protection #1063) — the branch from the
14361452
branches/<b>/protection URL path
14371453
bound_flags: list[str] (#1042) — sorted normalized privileged flags;

pact-plugin/tests/test_merge_guard_1129_r1_branch_set.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,12 @@
3434
detect_command_operation_type,
3535
extract_command_context,
3636
_extract_branch_delete_set,
37+
_canonical_join,
3738
)
3839
from merge_guard_pre import _token_matches_command # noqa: E402
3940

4041
# FORCE flag assembled at runtime — no raw `git branch -D` literal in the source.
4142
_D = "-" + "D"
42-
# The canonical branch_set join separator (NUL, ref-illegal → injective; F1 fix).
43-
_SEP = "\x00"
4443

4544

4645
def _cmd(flags: str, names: list[str]) -> str:
@@ -69,7 +68,7 @@ def test_multi_branch_force_delete_mints_branch_set_and_self_matches(flags):
6968
assert ctx.get("operation_type") == "branch-delete"
7069
# The additive key is populated with the canonical sorted identity, and the
7170
# scalar `branch` key stays ABSENT (mutual exclusivity).
72-
assert ctx.get("branch_set") == _SEP.join(["A", "B", "C"])
71+
assert ctx.get("branch_set") == _canonical_join(["A", "B", "C"])
7372
assert "branch" not in ctx
7473
# The faithful click now mints+matches (the over-block cure).
7574
assert _matches(cmd, cmd) is True
@@ -109,7 +108,7 @@ def test_duplicate_names_dedup_to_same_set():
109108

110109
def test_slashed_branch_names_mint_and_match():
111110
cmd = _cmd(_D, ["feature/x", "feature/y"])
112-
assert extract_command_context(cmd).get("branch_set") == _SEP.join(["feature/x", "feature/y"])
111+
assert extract_command_context(cmd).get("branch_set") == _canonical_join(["feature/x", "feature/y"])
113112
assert _matches(cmd, cmd) is True
114113

115114

@@ -126,7 +125,7 @@ def test_lowercase_delete_stays_ungated(flags):
126125
def test_branch_set_is_order_independent_canonical_string():
127126
a = _extract_branch_delete_set(_cmd(_D, ["A", "B", "C"]))
128127
b = _extract_branch_delete_set(_cmd(_D, ["C", "B", "A"]))
129-
assert a == b == _SEP.join(["A", "B", "C"])
128+
assert a == b == _canonical_join(["A", "B", "C"])
130129
assert isinstance(a, str) # STRING, never a tuple/list (JSON round-trip safety)
131130
# Fewer than two positionals -> None -> defers to the scalar `branch` path.
132131
assert _extract_branch_delete_set(_cmd(_D, ["solo"])) is None

pact-plugin/tests/test_merge_guard_1129_r1_cert.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -240,11 +240,11 @@ def test_persisted_token_branch_set_is_canonical_string(self, tmp_path):
240240
assert len(tokens) == 1
241241
payload = json.loads(tokens[0].read_text())
242242
# The token carries the shared-SSOT context; branch_set is the canonical,
243-
# sorted, NUL-joined STRING (#1135 F1 — NUL is git-forbidden in ref names, so
244-
# the joined identity is INJECTIVE; a list/tuple would have survived the JSON
243+
# sorted, netstring-framed STRING (the shared _canonical_join SSOT, #1136 —
244+
# injective by construction); a list/tuple would have survived the JSON
245245
# round-trip as a list and broken str-comparison matching).
246246
ctx = payload.get("context", payload)
247-
assert ctx.get("branch_set") == "aa\x00bb\x00cc"
247+
assert ctx.get("branch_set") == mgc._canonical_join(["aa", "bb", "cc"])
248248
assert isinstance(ctx.get("branch_set"), str)
249249
# And a reordered exec of the same set authorizes against the persisted token.
250250
assert _execute(_cmd(_D, ["bb", "cc", "aa"]), tmp_path) == ALLOW
@@ -260,7 +260,7 @@ class TestGatherCorrectness:
260260
def test_dash_flag_is_not_gathered_as_a_branch(self, tmp_path):
261261
# `-r` (a dash-flag) must be dropped; only the two real names form the set.
262262
cmd = _cmd(_D + " -r", ["origin/x", "origin/y"])
263-
assert mgc.extract_command_context(cmd).get("branch_set") == "origin/x\x00origin/y"
263+
assert mgc.extract_command_context(cmd).get("branch_set") == mgc._canonical_join(["origin/x", "origin/y"])
264264
minted, rc = _roundtrip(cmd, cmd, tmp_path)
265265
assert minted == 1
266266
assert rc == ALLOW
@@ -374,15 +374,15 @@ class TestCommaNameCollisionClosed:
374374
"""#1135 F1: git ref names PERMIT commas, so the OLD `,`-joined branch_set was
375375
non-injective — {aa,'bb,cc'} (2 branches) and {aa,bb,cc} (3 branches) both joined
376376
to `aa,bb,cc`, letting a 2-branch token authorize a 3-branch delete (a cross-set
377-
UNDER-BLOCK; the review witness). The NUL join (git-forbidden in ref names) makes
378-
the identity INJECTIVE: the two sets now produce distinct strings, so the token no
377+
UNDER-BLOCK; the review witness). The netstring _canonical_join SSOT (#1136) makes
378+
the identity INJECTIVE by construction: the two sets now produce distinct strings, so the token no
379379
longer cross-authorizes. The over-block direction is untouched (see the self-match)."""
380380

381381
def test_two_set_and_three_set_no_longer_collide(self):
382382
two = mgc._extract_branch_delete_set(_cmd(_D, ["aa", "bb,cc"])) # {aa, 'bb,cc'}
383383
three = mgc._extract_branch_delete_set(_cmd(_D, ["aa", "bb", "cc"])) # {aa, bb, cc}
384384
assert two != three, "comma-named 2-set must not collide with the 3-set (F1)"
385-
assert two == "aa\x00bb,cc" and three == "aa\x00bb\x00cc"
385+
assert two == mgc._canonical_join(["aa", "bb,cc"]) and three == mgc._canonical_join(["aa", "bb", "cc"])
386386

387387
def test_two_set_token_does_not_authorize_three_set_delete(self, tmp_path):
388388
# The review's cross-set under-block witness — now REFUSED.
@@ -434,7 +434,7 @@ def _q(self, names):
434434
return _GB + _D + " " + " ".join("'%s'" % n for n in names)
435435

436436
def test_quoted_names_canonicalize_quote_insensitively(self):
437-
assert mgc._extract_branch_delete_set(self._q(["aa", "bb"])) == "aa\x00bb"
437+
assert mgc._extract_branch_delete_set(self._q(["aa", "bb"])) == mgc._canonical_join(["aa", "bb"])
438438

439439
def test_quoted_mint_authorizes_unquoted_exec(self, tmp_path):
440440
minted, rc = _roundtrip(self._q(["aa", "bb"]), _cmd(_D, ["aa", "bb"]), tmp_path)

0 commit comments

Comments
 (0)