Skip to content

Commit f6e3639

Browse files
fix(journal): unify empty-team dedup posture via session-dir marker fallback (#1169) (#1171)
* feat(journal): session-dir marker fallback for empty-team snapshot dedup Empty-team frames previously fail-opened past the shared content-hash dedup marker (degenerate guard returns no target), making every eligible snapshot emit unconditional. Anchor the fallback marker root at the session dir — resolvable by construction whenever the journal is — so dedup resolvability equals journal resolvability. Two-root scheme: healthy-team markers do not move (default root_dir=None is byte-identical). - agent_handoff_marker: keyword-only root_dir plumbing; guard/pin flow stays single-copy; falsy or non-absolute root fails open as before - task_metadata_snapshot: _snapshot_marker_root policy helper consumed by both hard-bound wrappers (claim/rollback root symmetry by construction) - pact_context: docstring note on the effective deferral target - verification tests: fallback claim/suppress, two-root regression pin, defer-before-claim, degenerate root fail-open * test(journal): bidirectional certification for empty-team session-dir dedup fallback Seam-level cert suite (19 tests) coexisting with the substrate-surface verification file — one assertion per behavior across the two files: - close direction: per-write emit through the real gate path on an empty-team lead frame (delta-only payload pinned), symlink fault fails open with a recovery control leg, changed payload supersedes - open direction: identical repeat suppressed to one event, cross-seam dedup on the shared namespace, compensating unclaim under both append-failure shapes (claim/rollback root parity) - both-modes standing gate: lead emits+claims under the session root; in-process and tmux teammate frames stay fail-closed, keyed on structural signals only - guard-order pin: non-str team_name with degenerate task_id fails open instead of raising Non-vacuity proven by source-only revert (21 fail / 4 pass, passes are the unchanged-behavior pins) plus a predicate-force-open probe for the fail-closed matrix rows. Full suite: 11004 passed, 0 failed, 0 errors. * chore: bump plugin version to 4.6.4 * fix(journal): materialize fallback marker base at 0o700 The fallback claim's mkdir gave only the namespace leaf 0o700; a not-yet-materialized session dir was created as a mkdir parent at umask default, diverging from every other session-dir creator. Pre- create the base branch-locally (after the falsy/non-absolute guard, before the marker-dir join); OSError refuses to a fail-open emit, matching the shared flow's failure posture. exist_ok never chmods a pre-existing base — pinned under explicit umask control alongside a red-first mode pin. * test(journal): pin healing-transition cost at exactly one duplicate Team empty-to-healed with identical content: the healed emit appends (never suppresses the canonical event) and claims under the team root while the session-root marker population stays disjoint on disk; a further healed identical emit is suppressed, bounding the transition at one duplicate. Falsifier verified: an always-session-root neuter flips the never-suppression assertion.
1 parent 707238b commit f6e3639

9 files changed

Lines changed: 760 additions & 48 deletions

File tree

.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.6.3",
15+
"version": "4.6.4",
1616
"author": {
1717
"name": "Synaptic-Labs-AI"
1818
},

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.6.3/ # Plugin version
608+
│ └── 4.6.4/ # 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.6.3",
3+
"version": "4.6.4",
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.6.3
3+
> **Version**: 4.6.4
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/shared/agent_handoff_marker.py

Lines changed: 81 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ def _resolve_marker_target(
157157
occupant: str,
158158
*,
159159
namespace: str = _DEFAULT_MARKER_NAMESPACE,
160+
root_dir: "str | Path | None" = None,
160161
) -> "tuple[int | None, str | None]":
161162
"""
162163
Sanitize + validate + pin the marker directory; return the open directory
@@ -169,6 +170,16 @@ def _resolve_marker_target(
169170
sites is the #877/#878 parallel-path-rot class — a single derivation makes
170171
alignment structural rather than a convention two sites must each remember.
171172
173+
root_dir (keyword-only, default None) is a caller-owned root override:
174+
when provided, team_name is IGNORED (the caller owns root policy) and the
175+
marker dir becomes {root_dir}/{namespace} with root_dir itself as the
176+
containment base. A falsy or non-absolute root_dir is treated as "no
177+
valid target" (same fail-open signal as a degenerate team). The default
178+
None keeps the team-scoped derivation byte-identical to the pre-override
179+
behavior. Only the (marker_dir, containment base) selection branches —
180+
the guard/pin flow below is single-copy for both roots, so the override
181+
cannot diverge from the team path's symlink/TOCTOU/dir_fd hazards.
182+
172183
Returns (dir_fd, filename) on success — the caller MUST os.close(dir_fd).
173184
Returns (None, None) on any degenerate-key / symlink / containment /
174185
resolution failure; the caller fail-opens (already_emitted → emit;
@@ -179,42 +190,67 @@ def _resolve_marker_target(
179190
to the fd, cannot be redirected through a symlinked directory swapped in
180191
after this resolution.
181192
"""
182-
# Centralized normalization (SSOT): case-fold + sanitize team_name HERE so
183-
# every caller derives a BYTE-IDENTICAL marker dir regardless of what it
184-
# passed in — b1 (agent_handoff_emitter) pre-lowercases for its
185-
# read_task_json path, b2 (task_lifecycle_gate) does not; folding .lower()
186-
# in here makes the two structurally identical and closes the dormant
187-
# case-drift (the #887 divergence shape one level up). Idempotent w.r.t.
188-
# b1's external .lower(). The normalized value flows into BOTH _marker_dir()
189-
# and the TOCTOU team_base below, keeping the containment check consistent.
190-
team_name = sanitize_path_component(team_name.lower())
191193
task_id = sanitize_path_component(task_id)
192194
occupant = sanitize_path_component(occupant)
193195

194196
# Degenerate post-sanitization values collapse the marker path onto an
195-
# existing directory:
196-
# _marker_dir(".") → .../teams/./.agent_handoff_emitted
197-
# _marker_dir("..") → .../teams/../.agent_handoff_emitted
198-
# For the filename, the composite key f"{task_id}-{occupant}" no longer
199-
# collapses on a degenerate task_id alone ("." → ".-<hash>", a normal
200-
# file) — but the task_id guard is retained for defense + behavioral
201-
# parity with the pre-occupant key, and a missing occupant (only possible
202-
# if a caller bypasses occupant_hash()) is treated as "no valid key".
203-
# In every degenerate case: signal "no valid target" so the caller
204-
# fail-opens (already_emitted emits rather than suppresses; unclaim no-ops).
205-
if (
206-
not team_name
207-
or team_name in (".", "..")
208-
or not task_id
209-
or task_id in (".", "..")
210-
or not occupant
211-
):
197+
# existing directory (see the team guard below for the dir-collapse
198+
# shape). For the filename, the composite key f"{task_id}-{occupant}" no
199+
# longer collapses on a degenerate task_id alone ("." → ".-<hash>", a
200+
# normal file) — but the task_id guard is retained for defense +
201+
# behavioral parity with the pre-occupant key, and a missing occupant
202+
# (only possible if a caller bypasses occupant_hash()) is treated as "no
203+
# valid key". These key guards apply under EITHER root. In every
204+
# degenerate case: signal "no valid target" so the caller fail-opens
205+
# (already_emitted emits rather than suppresses; unclaim no-ops).
206+
if not task_id or task_id in (".", "..") or not occupant:
212207
return None, None
213208

214-
marker_dir = _marker_dir(team_name, namespace=namespace)
209+
if root_dir is None:
210+
# Centralized normalization (SSOT): case-fold + sanitize team_name
211+
# HERE so every caller derives a BYTE-IDENTICAL marker dir regardless
212+
# of what it passed in — b1 (agent_handoff_emitter) pre-lowercases
213+
# for its read_task_json path, b2 (task_lifecycle_gate) does not;
214+
# folding .lower() in here makes the two structurally identical and
215+
# closes the dormant case-drift (the #887 divergence shape one level
216+
# up). Idempotent w.r.t. b1's external .lower(). The normalized value
217+
# flows into BOTH _marker_dir() and the TOCTOU containment base,
218+
# keeping the containment check consistent.
219+
#
220+
# Degenerate team values collapse the marker path onto an existing
221+
# directory:
222+
# _marker_dir(".") → .../teams/./.agent_handoff_emitted
223+
# _marker_dir("..") → .../teams/../.agent_handoff_emitted
224+
team_name = sanitize_path_component(team_name.lower())
225+
if not team_name or team_name in (".", ".."):
226+
return None, None
227+
marker_dir = _marker_dir(team_name, namespace=namespace)
228+
base = get_claude_config_dir() / "teams" / team_name
229+
else:
230+
# Caller-owned root: team_name is ignored (root policy lives with
231+
# the caller). A falsy or relative root cannot anchor a containment
232+
# check → no valid target, fail-open.
233+
if not root_dir or not Path(root_dir).is_absolute():
234+
return None, None
235+
base = Path(root_dir)
236+
# Pre-create the base itself at 0o700 when absent: the shared mkdir
237+
# below applies mode= to the FINAL component (the namespace dir)
238+
# only, so parents it creates get umask-default permissions — a
239+
# claim that materializes a missing base (e.g. a session dir the
240+
# journal writer has not created yet) would otherwise leave it
241+
# looser than every other creator, which mkdirs the base as its own
242+
# 0o700 leaf. exist_ok keeps an already-present base's mode
243+
# untouched; on failure, refuse the target (fail-open emit),
244+
# consistent with the shared mkdir's OSError posture below.
245+
try:
246+
base.mkdir(parents=True, exist_ok=True, mode=0o700)
247+
except OSError:
248+
return None, None
249+
marker_dir = base / namespace
250+
215251
# Symlink-containment pre-check: if marker_dir already exists as a
216252
# symlink, refuse to use it (a pre-planted symlink could redirect marker
217-
# creation outside the team directory). Fail-open emit rather than risk
253+
# creation outside the containment base). Fail-open emit rather than risk
218254
# writing to an attacker-controlled location.
219255
if marker_dir.is_symlink():
220256
return None, None
@@ -226,19 +262,18 @@ def _resolve_marker_target(
226262

227263
# TOCTOU containment re-check (closes the window between the is_symlink()
228264
# pre-check above and this mkdir): a symlink race could swap marker_dir
229-
# to a directory OUTSIDE the team base between the two operations, and
230-
# mkdir(exist_ok=True) silently follows an existing symlink. Re-resolve
231-
# both paths and verify marker_dir is still contained within the team
232-
# base. commonpath (not str.startswith — defeats the /teams/foo vs
265+
# to a directory OUTSIDE the containment base between the two operations,
266+
# and mkdir(exist_ok=True) silently follows an existing symlink.
267+
# Re-resolve both paths and verify marker_dir is still contained within
268+
# the base. commonpath (not str.startswith — defeats the /teams/foo vs
233269
# /teams/foobar prefix-collision) is the robust containment test; chosen
234270
# over Path.is_relative_to because pyproject pins requires-python >=3.7
235271
# and is_relative_to is 3.9+. On breach OR any resolution error: fail-open
236272
# emit (return None) WITHOUT writing a marker at the escaped path —
237273
# consistent with the is_symlink() pre-check's fail-open posture.
238-
team_base = get_claude_config_dir() / "teams" / team_name
239274
try:
240275
real_marker = os.path.realpath(marker_dir)
241-
real_base = os.path.realpath(team_base)
276+
real_base = os.path.realpath(base)
242277
if os.path.commonpath([real_marker, real_base]) != real_base:
243278
return None, None
244279
except (OSError, ValueError):
@@ -273,10 +308,15 @@ def already_emitted(
273308
occupant: str,
274309
*,
275310
namespace: str = _DEFAULT_MARKER_NAMESPACE,
311+
root_dir: "str | Path | None" = None,
276312
) -> bool:
277313
"""
278314
Test-and-set the per-(team, task_id, occupant) marker.
279315
316+
root_dir is a caller-policy root override (see _resolve_marker_target),
317+
resolved through the same SSOT as unclaim() so the claim and the rollback
318+
can never diverge.
319+
280320
Returns True iff a prior fire for the same key already created the marker
281321
(caller should suppress the journal write). Returns False on fresh fires —
282322
the marker is created as a side-effect of this call, making the
@@ -308,7 +348,7 @@ def already_emitted(
308348
process claimed but whose journal write then failed) — see unclaim().
309349
"""
310350
dir_fd, filename = _resolve_marker_target(
311-
team_name, task_id, occupant, namespace=namespace
351+
team_name, task_id, occupant, namespace=namespace, root_dir=root_dir
312352
)
313353
if dir_fd is None:
314354
# Degenerate key / symlink-guarded / unresolvable → fail-open emit.
@@ -337,6 +377,7 @@ def unclaim(
337377
occupant: str,
338378
*,
339379
namespace: str = _DEFAULT_MARKER_NAMESPACE,
380+
root_dir: "str | Path | None" = None,
340381
) -> None:
341382
"""
342383
Compensating rollback (#901, R1) — remove the marker THIS process just
@@ -346,6 +387,10 @@ def unclaim(
346387
suppresses every later fire for the key forever (the silent-permanent-loss
347388
residual the writability gate only narrowed, not closed).
348389
390+
root_dir is a caller-policy root override (see _resolve_marker_target),
391+
resolved through the same SSOT as already_emitted() so the rollback can
392+
never target a root that diverges from the claim.
393+
349394
Caller contract: invoke ONLY when this process OWNS the marker — i.e.
350395
already_emitted() returned False on THIS fire (a fresh O_EXCL create).
351396
Calling it after a True (prior-fire-owns) result could remove a marker a
@@ -363,7 +408,7 @@ def unclaim(
363408
marker) — strictly no worse than not having the rollback. Never raises.
364409
"""
365410
dir_fd, filename = _resolve_marker_target(
366-
team_name, task_id, occupant, namespace=namespace
411+
team_name, task_id, occupant, namespace=namespace, root_dir=root_dir
367412
)
368413
if dir_fd is None:
369414
return

pact-plugin/hooks/shared/pact_context.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -954,7 +954,11 @@ def is_canonical_journal_frame(input_data: dict) -> bool:
954954
completion-time seams — never worse than the shipped baseline — while
955955
emitting from a misclassified frame could silo the event AND poison
956956
the shared content-hash marker namespace, suppressing a later
957-
canonical emit. The is_lead leg is independent of config readability,
957+
canonical emit. On an empty-team frame the teammate-completion seams
958+
starve by construction (no team namespace to read task state from),
959+
so the effective deferral target is the lead-completion seam, whose
960+
snapshot substrate dedups under the session-dir marker root when the
961+
team is empty. The is_lead leg is independent of config readability,
958962
so lead-written keys keep full both-modes coverage even when the
959963
topology leg cannot resolve. Never raises.
960964
"""

pact-plugin/hooks/shared/task_metadata_snapshot.py

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,12 @@
2828
Supersession: multiple snapshots per task are legal — a changed payload
2929
after completion re-emits under a new content key; an unchanged payload
3030
never re-emits (the content-keyed O_EXCL marker dedups across all seams).
31-
Readers take latest-ts within the (task_id, occupant) group; the occupant
32-
field is the task-id-reuse discriminator (platform reuses task ids across
33-
arcs within one team).
31+
The content-key marker claims under the team root, or under the session-dir
32+
root when team_name is empty (dedup-resolvable ⇔ journal-resolvable — both
33+
derive from the same session context, so an emit that can reach the journal
34+
can always claim its dedup marker). Readers take latest-ts within the
35+
(task_id, occupant) group; the occupant field is the task-id-reuse
36+
discriminator (platform reuses task ids across arcs within one team).
3437
3538
Size-bounding invariant: key EXISTENCE is never silently lost — worst case
3639
a key survives name-only in the payload's top-level "_dropped_keys" list.
@@ -52,6 +55,7 @@
5255
sanitize_path_component,
5356
unclaim,
5457
)
58+
from .pact_context import get_session_dir
5559
from .session_journal import append_event, get_journal_path, make_event
5660

5761
# Keys never mirrored: only entries with equivalent-or-better journal
@@ -110,6 +114,23 @@
110114
# other. Module constant, never input-derived.
111115
SNAPSHOT_MARKER_NAMESPACE: str = ".task_metadata_snapshot_emitted"
112116

117+
118+
def _snapshot_marker_root(team_name: str) -> str | None:
119+
"""Marker-root policy for the snapshot family (the ONE place it lives).
120+
121+
Non-empty team -> None (default team-scoped root, path-identical to
122+
the pre-fallback behavior). Empty team -> the session directory, so
123+
dedup resolvability is equal-by-construction to journal resolvability
124+
(get_journal_path derives from the same get_session_dir); the
125+
"empty team + resolvable journal" fail-open gap cannot reopen. An
126+
unresolvable session dir returns "" — the resolver treats a falsy
127+
root as no-valid-target and fail-opens, which the emit path never
128+
reaches (its writability precondition already returned).
129+
"""
130+
if team_name:
131+
return None
132+
return get_session_dir()
133+
113134
# Marker-dict key set used to recognize truncation markers this module
114135
# itself produced (stage-2 candidate filtering + stage-3 head emptying).
115136
_MARKER_KEYS = frozenset({"_truncated", "original_bytes", "head"})
@@ -341,21 +362,33 @@ def snapshot_already_emitted(
341362
marker functions: a forgotten namespace arg would claim in one dir and
342363
no-op-unclaim against the other, leaving a poisoned marker; the wrapper
343364
makes that impossible by construction.
365+
366+
The marker root is likewise hard-bound: it comes from
367+
_snapshot_marker_root, the family's single root-policy site, so the
368+
claim and the rollback derive the same root by construction.
344369
"""
345370
return already_emitted(
346-
team_name, task_id, content_key, namespace=SNAPSHOT_MARKER_NAMESPACE
371+
team_name,
372+
task_id,
373+
content_key,
374+
namespace=SNAPSHOT_MARKER_NAMESPACE,
375+
root_dir=_snapshot_marker_root(team_name),
347376
)
348377

349378

350379
def snapshot_unclaim(team_name: str, task_id: str, content_key: str) -> None:
351380
"""Compensating rollback for a claim whose journal write failed.
352381
353382
Hard-bound twin of snapshot_already_emitted — same namespace constant,
354-
same resolver SSOT underneath, so the claim and the rollback can never
355-
reference divergent paths.
383+
same root policy (_snapshot_marker_root), same resolver SSOT underneath,
384+
so the claim and the rollback can never reference divergent paths.
356385
"""
357386
unclaim(
358-
team_name, task_id, content_key, namespace=SNAPSHOT_MARKER_NAMESPACE
387+
team_name,
388+
task_id,
389+
content_key,
390+
namespace=SNAPSHOT_MARKER_NAMESPACE,
391+
root_dir=_snapshot_marker_root(team_name),
359392
)
360393

361394

0 commit comments

Comments
 (0)