fix(gate): review round 3 --- canonical ancestry, guard witnesses, and a withdrawn claim

**THE ANCESTOR WALK WAS WRONG TWICE OVER.** `for _anc in $(...)`
word-splits on IFS, so a gate root containing a SPACE was torn into
fragments and the real ancestor never tested --- the check passed on
exactly the path it should reject. And `dirname` walks LEXICAL
ancestry while `detect_project` canonicalizes, so a symlinked root hid
a marker the editor plainly sees. The walk resolves with `pwd -P` first
and iterates a quoted `while`; both shapes are verified by hand
(space-containing root refused, symlinked root refused at its real
path).

**THE 103-BYTE GUARD HAD NO WITNESS AT ALL** --- every other row runs
with a short root, so the guard is silent and a broken one looked
identical. Three rows now aim at it deliberately: boundary rejection
and acceptance, a MULTIBYTE root (each `é` is one character and two
bytes, so it is rejected only if the guard measures bytes), and
**rejection must reap both created areas**, which is the leak the early
trap exists to prevent.

**The `Cargo.toml`-DIRECTORY case was claimed and not covered**, and
the consequence is exactly as review predicted: reverting only the
language-marker arm to `[ -e ]` stayed green. The marker-type row now
drives all three shapes, and `M-G-5` --- that precise revert --- fails
it.

**Prose brought level with the implementation.** The framing, the
handoff and the ledger all said 108; the supported floor is **103
usable bytes**, Darwin's 104-byte array minus its NUL. The ledger also
still said `<pid>`, the superseded 21/30 reserve, and `M-G-1`.

**And the ruling said nested gates "do not pay" the reserve, which is
false and would have licensed exempting them.** They pay it in full;
the short layout merely gives them the headroom to satisfy an unchanged
production guard. Reworded, because the wrong version is the one a
future reader would act on.

**THE btrfs CAUSAL CLAIM IS WITHDRAWN.** The draft argued that a
one-second deadline plus a slower filesystem was a plausible new
mechanism for the fourth `managed_retry` occurrence. It does not
survive inspection: the deadline bounds the connection RETRY loop, not
the socketpair handshake that returned `BrokenPipe`, and the filesystem
work happens before it is armed --- the tempdir is created and never
bound. The environmental change is still recorded, as a CHANGE rather
than a mechanism, so a later occurrence can compare like with like.
Recording a mechanism the code does not support is worse than
recording none: the next occurrence gets measured against a story
instead of the evidence. TMPDIR stays disk-backed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
This commit is contained in:
Levi Neuwirth 2026-08-13 17:07:55 +02:00
parent 465acae547
commit 0a04d55a35
No known key found for this signature in database
6 changed files with 228 additions and 69 deletions

View File

@ -288,7 +288,8 @@ from #171 and #215.
responsibility to `TMPDIR` rather than adding a feature, which is why responsibility to `TMPDIR` rather than adding a feature, which is why
it amends this document instead of opening a new one. it amends this document instead of opening a new one.
- **What it does:** every gate invocation gets a fresh, disk-backed - **What it does:** every gate invocation gets a fresh, disk-backed
`TMPDIR` at `<gate-root>/tmp/<pid>`, exported once so every stage and `TMPDIR` at `<gate-root>/tmp/<mktemp>`, exported once so every stage
and
every process they spawn inherits it, and reaped by the same exit trap every process they spawn inherits it, and reaped by the same exit trap
as the ambient root. **A gate run no longer needs a `TMPDIR=` as the ambient root. **A gate run no longer needs a `TMPDIR=`
override.** override.**
@ -296,8 +297,11 @@ from #171 and #215.
ANCESTOR marker — project detection walks upward — so a fresh ANCESTOR marker — project detection walks upward — so a fresh
subdirectory of `/tmp` inherits `/tmp`'s ancestors and the same stray subdirectory of `/tmp` inherits `/tmp`'s ancestors and the same stray
`.git`. The directory had to move somewhere the gate already owns. `.git`. The directory had to move somewhere the gate already owns.
- **`SUN_LEN` is the constraint that shaped the layout, and the fix's - **The socket-path limit shaped the layout, and the fix's own gate run
own gate run found it.** A Unix socket path cannot exceed 108 bytes found it.** The budget is the **supported-platform floor of 103
usable bytes** — Darwin's 104-byte array minus its terminating NUL,
not Linux's 108, because a Linux-derived limit passes where it is
written and bind-fails on the macOS leg. A path cannot exceed that
and the suites bind sockets inside `TMPDIR`. The first placement, and the suites bind sockets inside `TMPDIR`. The first placement,
`$TARGET/gate-tmp/$STAMP-$$`, produced a 114-byte socket path and `$TARGET/gate-tmp/$STAMP-$$`, produced a 114-byte socket path and
failed **six** daemon and attach tests with *"path must be shorter failed **six** daemon and attach tests with *"path must be shorter
@ -308,16 +312,25 @@ from #171 and #215.
socket failures deep in a suite name a limit, not a cause. The guard socket failures deep in a suite name a limit, not a cause. The guard
fails immediately with the path, its length and what to shorten. fails immediately with the path, its length and what to shorten.
**Its reserve is measured, not round**: the longest suffix a fixture **Its reserve is measured, not round**: the longest suffix a fixture
appends is 21 bytes, so 30 leaves ~40% headroom. An earlier appends is **33** bytes (`/.tmpXXXXXX/directory-target.sock`), so
"generous" 45 **fired on the gate's own behaviour tests**, which run **48** leaves ~45% headroom. Two earlier values were wrong in
the gate inside the gate and so reach 71 bytes — a guard that rejects OPPOSITE directions — a "generous" 45 that fired on the gate's own
a legitimate configuration fails on every run rather than a rare one. behaviour tests, then a 30 that sat **below the real maximum** and
- **Witnesses (2), each mutation-checked:** `M-G-1` removes the export would have passed 76–78-byte paths. The suite now roots its nested
→ the propagation row alone; `M-G-2` stops the reaping → the cleanup gates at a short base so it can SATISFY the unchanged production
row alone. Propagation is observed in a **spawned child** (the guard rather than be exempted from it.
self-test's first step reports its own `$TMPDIR` into its log), - **Witnesses, each mutation-checked.** `M-G-1b` keeps the assignment
because the gate exporting a variable would only prove the gate can and removes only `export` → the propagation row; its predecessor
export a variable. `M-G-1` deleted both and so never proved inheritance. `M-G-2` stops
the reaping → the cleanup row. `M-G-3` removes the ancestor check →
the refusal row. `M-G-4` reverts to existence-only, and `M-G-5`
reverts **only** the language-marker arm → the marker-type row, which
is why that row covers a `Cargo.toml` **directory** as well as both
`.git` shapes. `M-G-6` counts characters → the multibyte row.
`M-G-7` moves the trap back after the guards → the
rejection-cleanup row. Propagation is observed in a **spawned
child**, because the gate exporting a variable would only prove the
gate can export a variable.
- **Proved against the live hazard:** `/tmp/.git` is still present on - **Proved against the live hazard:** `/tmp/.git` is still present on
this machine, and the tests it reddened now pass with **no override**. this machine, and the tests it reddened now pass with **no override**.
- **Gates:** `./scripts/gate --acceptance gate_script_acceptance`, run - **Gates:** `./scripts/gate --acceptance gate_script_acceptance`, run

View File

@ -283,8 +283,12 @@ commands, read `docs/active-work.md` immediately after this file.
**FIXED 2026-08-13: `scripts/gate` now isolates `TMPDIR`.** Each **FIXED 2026-08-13: `scripts/gate` now isolates `TMPDIR`.** Each
invocation gets a directory created fresh by `mktemp -d` under invocation gets a directory created fresh by `mktemp -d` under
**`<gate-root>/tmp/`** — the shared gate root, not the per-worktree **`<gate-root>/tmp/`** — the shared gate root, not the per-worktree
target, because a Unix socket path cannot exceed **`SUN_LEN`** (108 target, because a Unix socket path cannot exceed `sun_path` and the
bytes) and the suites bind sockets inside `TMPDIR`. It is exported suites bind sockets inside `TMPDIR`. **The budget is the
supported-platform floor: 103 usable bytes** — Darwin's 104-byte
array minus its terminating NUL, not Linux's 108, because a
Linux-derived limit passes where it is written and bind-fails on
the macOS leg. It is exported
once so every stage and every process they spawn inherits it, and once so every stage and every process they spawn inherits it, and
reaped by the same exit trap as the ambient root. **A gate run no reaped by the same exit trap as the ambient root. **A gate run no
longer needs a `TMPDIR=` override.** longer needs a `TMPDIR=` override.**

View File

@ -552,27 +552,32 @@ is empty) and adds **no test to that binary**. Occurrence 3 excluded
reproduces the signature with *nothing added to the binary*, which is reproduces the signature with *nothing added to the binary*, which is
independent corroboration rather than a repeat of the same argument. independent corroboration rather than a repeat of the same argument.
**AND ONE THING IT INTRODUCES, NAMED RATHER THAN DISMISSED — the **The observing lane is environmentally non-neutral, and that is
observing lane is not environmentally neutral even though it is recorded as a CHANGE rather than as a mechanism.** It moves the gate's
code-neutral.** That lane moves the gate's `TMPDIR` off `/tmp`, which `TMPDIR` off `/tmp`, which on this machine puts every
on this machine changes the filesystem underneath every `tempfile::tempdir()` in the run on btrfs instead of tmpfs. Noted so a
`tempfile::tempdir()` in the run **from tmpfs to btrfs**. This test later occurrence can compare like with like.
creates a tempdir and runs a handshake against a **one-second
deadline** (`pmacs-gpu/src/attach.rs`). A slower filesystem under a
timing-bounded test, under full-sweep load, is a plausible mechanism
and **it did not exist in occurrences 1–3**.
It is not established either: the failing I/O is on a `UnixStream::pair`, **A causal claim built on that was advanced here and is WITHDRAWN.**
not on the tempdir path, and the tempdir is created but never bound. The draft argued the test's one-second deadline plus a slower
**What this row must not do is book the occurrence as "the usual flake" filesystem was a plausible new mechanism. It does not hold on
when the observing lane changed the very conditions the flake is inspection: **the deadline bounds the connection RETRY loop, not the
sensitive to.** socketpair handshake that returned `BrokenPipe`**, and the filesystem
work happens before that deadline is armed. The tempdir is created and
never bound — the failing I/O is on a `UnixStream::pair`. Recording a
mechanism that the code does not support is worse than recording none,
because the next occurrence gets measured against a story instead of
against the evidence.
**The discriminating comparison for a fifth occurrence** is therefore **So the causal status is unchanged by this occurrence: UNRESOLVED,
the same lane's gate run with `TMPDIR` pointed back at a tmpfs — the with no new mechanism.** What it adds is the corroboration above. Three
one variable this lane moves — rather than another merge-base control. isolated re-runs on the current tree were green, which by this file's
Three isolated re-runs on the current tree were green, which by this own rule establishes intermittence only.
file's own rule establishes intermittence only.
**The discriminating comparison for a fifth occurrence** remains the
one the third occurrence prescribed. One occurrence, with no supported
mechanism, is not grounds to reverse a fix that closes two observed
hazards.
**Third occurrence — worker identity Stage 1 review round 2, **Third occurrence — worker identity Stage 1 review round 2,
2026-08-09, local (Linux). Same selector, same `gpu`-step flavor, all 2026-08-09, local (Linux). Same selector, same `gpu`-step flavor, all

View File

@ -452,7 +452,9 @@ Four decisions inside that, each of which had a cheaper wrong answer:
therefore the marker. The directory has to sit somewhere with no therefore the marker. The directory has to sit somewhere with no
marker above it. marker above it.
2. **Off the GATE ROOT, not the per-worktree target.** A Unix socket 2. **Off the GATE ROOT, not the per-worktree target.** A Unix socket
path cannot exceed `SUN_LEN` (108 bytes) and the suites bind sockets path cannot exceed `sun_path` — **103 usable bytes at the supported
floor** (Darwin's 104-byte array minus its terminating NUL; Linux's
is 108/107) and the suites bind sockets
*inside* `TMPDIR`. The per-worktree target is 60 bytes and the gate *inside* `TMPDIR`. The per-worktree target is 60 bytes and the gate
root 36; the first implementation used the former and produced root 36; the first implementation used the former and produced
114-byte socket paths, failing six daemon and attach tests. **The 114-byte socket paths, failing six daemon and attach tests. **The
@ -492,8 +494,10 @@ other, which is the worst place to find out. **The usable PATH length is
one less than the array**, because the stored value is NUL-terminated: one less than the array**, because the stored value is NUL-terminated:
103 on Darwin, 107 on Linux. The script takes **103**. 103 on Darwin, 107 on Linux. The script takes **103**.
**RULING — a synthetic nested gate does not pay a reserve it never **RULING — a synthetic nested gate is given room to SATISFY the
uses.** The reserve exists for fixtures that bind sockets under reserve; it is not exempted from it.** The guard is unchanged for every
run, nested or not. What changed is the layout the behaviour suite
hands it. The reserve exists for fixtures that bind sockets under
`TMPDIR`. This script's own behaviour suite runs *nested* gates whose `TMPDIR`. This script's own behaviour suite runs *nested* gates whose
plans are synthetic (`true`, `false`, one `echo`) and which bind no plans are synthetic (`true`, `false`, one `echo`) and which bind no
socket at all, so applying the fixture reserve to them would reject a socket at all, so applying the fixture reserve to them would reject a
@ -516,10 +520,13 @@ rather than the guard weakened**:
plans create no markerless fixture — the same reason it may set the plans create no markerless fixture — the same reason it may set the
ancestor escape. ancestor escape.
**The guard therefore keeps the true maximum for real runs**, and the **The guard therefore keeps the true maximum for every run**, and the
tests stop paying for a hazard they cannot encounter. If a future suite simply stops handing it a root that cannot clear it. An earlier
behaviour row *does* bind a socket, it must move off the short base and draft of this section said nested gates "do not pay" the reserve, which
take the reserve with it. is wrong and would have licensed exempting them: they pay it in full
and now have the headroom to afford it. If a future behaviour row binds
a socket, it must move off the short base — the reserve already covers
it.
**Escape hatch, documented test-only.** **Escape hatch, documented test-only.**
`PMACS_GATE_ALLOW_ANCESTOR_MARKER` exists for this script's own `PMACS_GATE_ALLOW_ANCESTOR_MARKER` exists for this script's own

View File

@ -639,42 +639,45 @@ fi
# markerless fixture exists for a marker to re-root. The check itself # markerless fixture exists for a marker to re-root. The check itself
# is witnessed by a test that deliberately does NOT set this and # is witnessed by a test that deliberately does NOT set this and
# asserts the refusal. # asserts the refusal.
gate_marker_refusal() {
echo "gate: a project marker sits above the gate TMPDIR:" >&2
echo "gate: $1" >&2
echo "gate: TMPDIR is $GATE_TMPDIR" >&2
echo "gate: every markerless test fixture beneath it would be" >&2
echo "gate: re-rooted at that directory. Move the gate root" >&2
echo "gate: (PMACS_GATE_TARGET_ROOT) somewhere without one." >&2
exit 2
}
if [ -z "${PMACS_GATE_ALLOW_ANCESTOR_MARKER:-}" ]; then if [ -z "${PMACS_GATE_ALLOW_ANCESTOR_MARKER:-}" ]; then
for _anc in $( # CANONICAL, AND QUOTED. Two defects an obvious loop has:
_p="$GATE_TMPDIR" #
while [ "$_p" != "/" ] && [ -n "$_p" ]; do # * `for _anc in $(...)` WORD-SPLITS on IFS, so a gate root containing
printf '%s\n' "$_p" # a space is torn into fragments and the real ancestor is never
_p=$(dirname "$_p") # tested --- the check would pass on exactly the path it should
done # reject.
printf '/\n' # * `dirname` walks LEXICAL ancestry. `detect_project` canonicalizes,
); do # so a symlinked root can hide a marker the editor plainly sees.
# TYPE MATTERS, and an existence-only test is wrong in both # Resolving first makes the two agree.
# directions. `match_marker` in `src/project.rs` requires `.git` to _anc=$(cd "$GATE_TMPDIR" 2>/dev/null && pwd -P) || _anc="$GATE_TMPDIR"
# be a DIRECTORY and the seven language markers to be FILES, so while :; do
# `[ -e ]` would reject ancestors detection itself ignores --- most
# importantly a `.git` FILE, which is exactly what a git WORKTREE
# has. Every worktree in this repo would have tripped it.
for _m in Cargo.toml .luarc.json pyproject.toml go.mod deno.json \ for _m in Cargo.toml .luarc.json pyproject.toml go.mod deno.json \
deno.jsonc package.json; do deno.jsonc package.json; do
# TYPE MATTERS, and an existence-only test is wrong in both
# directions. `match_marker` in `src/project.rs` requires `.git`
# to be a DIRECTORY and the seven language markers to be FILES,
# so `[ -e ]` would reject ancestors detection itself ignores.
if [ -f "$_anc/$_m" ]; then if [ -f "$_anc/$_m" ]; then
echo "gate: a project marker sits above the gate TMPDIR:" >&2 gate_marker_refusal "$_anc/$_m"
echo "gate: $_anc/$_m" >&2
echo "gate: TMPDIR is $GATE_TMPDIR" >&2
echo "gate: every markerless test fixture beneath it would be" >&2
echo "gate: re-rooted at that directory. Move the gate root" >&2
echo "gate: (PMACS_GATE_TARGET_ROOT) somewhere without one." >&2
exit 2
fi fi
done done
# The one directory-valued marker. A git WORKTREE has a `.git`
# FILE, which detection ignores and this must too.
if [ -d "$_anc/.git" ]; then if [ -d "$_anc/.git" ]; then
echo "gate: a project marker sits above the gate TMPDIR:" >&2 gate_marker_refusal "$_anc/.git"
echo "gate: $_anc/.git" >&2
echo "gate: TMPDIR is $GATE_TMPDIR" >&2
echo "gate: every markerless test fixture beneath it would be" >&2
echo "gate: re-rooted at that directory. Move the gate root" >&2
echo "gate: (PMACS_GATE_TARGET_ROOT) somewhere without one." >&2
exit 2
fi fi
[ "$_anc" = "/" ] && break
_anc=$(dirname "$_anc")
done done
fi fi

View File

@ -694,7 +694,21 @@ fn the_ancestor_check_honours_marker_types() {
); );
// The same name as a DIRECTORY is a real marker. // The same name as a DIRECTORY is a real marker.
// A LANGUAGE marker as a DIRECTORY: detection wants a file, so the
// guard must ignore it. Without this row, reverting only the
// language-marker arm to `[ -e ]` stays green — the `.git` halves
// below constrain the directory-valued marker alone.
std::fs::remove_file(&mine).expect("rm git file"); std::fs::remove_file(&mine).expect("rm git file");
let cargo_dir = base.path().join("Cargo.toml");
std::fs::create_dir(&cargo_dir).expect("cargo dir");
let err = String::from_utf8_lossy(&run_it().stderr).into_owned();
assert!(
!err.contains(&format!("{}", cargo_dir.display())),
"a `Cargo.toml` DIRECTORY is not a marker to detection and must \
not be one here; stderr:\n{err}"
);
std::fs::remove_dir(&cargo_dir).expect("rm cargo dir");
std::fs::create_dir(&mine).expect("git dir"); std::fs::create_dir(&mine).expect("git dir");
let out = run_it(); let out = run_it();
let err = String::from_utf8_lossy(&out.stderr).into_owned(); let err = String::from_utf8_lossy(&out.stderr).into_owned();
@ -708,6 +722,119 @@ fn the_ancestor_check_honours_marker_types() {
); );
} }
/// The socket-path guard: rejection, acceptance, and cleanup on
/// rejection.
///
/// **The ordinary gate only ever exercises the passing side.** Every
/// other row here runs with a short root, so the guard is silent and a
/// broken guard would look identical. These construct the boundary
/// deliberately.
///
/// The budget is **103 usable bytes** — Darwin's `sun_path[104]` minus
/// its terminating NUL, the supported-platform floor — less a 48-byte
/// fixture reserve, so a root whose derived TMPDIR exceeds 55 bytes is
/// refused.
#[test]
fn the_socket_path_guard_rejects_accepts_and_cleans_up() {
// The gate appends `/tmp/XXXXXX` (11 bytes) to the root, so a root
// of N bytes yields a TMPDIR of N + 11.
let over_root = long_root(60);
let out = run_guarded(over_root.path());
let err = String::from_utf8_lossy(&out.stderr).into_owned();
assert!(
!out.status.success(),
"a root past the budget must be refused; stderr:\n{err}"
);
assert!(
err.contains("too long for a unix socket path"),
"and must say why; stderr:\n{err}"
);
// REJECTION MUST NOT LEAK. The guard creates both temporary areas
// before it can measure anything, so a rejection that exits before
// the trap is armed leaves them behind — on every rejection, which
// is worse than the failure it prevents.
let leaked: Vec<_> = walkdir_shallow(over_root.path());
assert!(
leaked.is_empty(),
"a rejected run must reap what it created; found {leaked:?}"
);
// Just inside the budget: accepted.
let ok_root = long_root(40);
let out = run_guarded(ok_root.path());
let err = String::from_utf8_lossy(&out.stderr).into_owned();
assert!(
!err.contains("too long for a unix socket path"),
"a root inside the budget must not be refused; stderr:\n{err}"
);
}
/// The guard counts BYTES, not characters.
///
/// `${#var}` counts characters under a UTF-8 locale while `sun_path` is
/// byte-limited, so a multibyte path measures short and passes a check
/// it should fail. Each `é` here is one character and **two bytes**.
#[test]
fn the_socket_path_guard_counts_bytes_not_characters() {
// Character-length ~34 but byte-length ~68: rejected only if the
// guard measures bytes.
let root = tempfile::Builder::new()
.prefix(&"é".repeat(24))
.tempdir_in(short_root_base())
.expect("multibyte root");
let chars = root.path().to_string_lossy().chars().count();
let bytes = root.path().to_string_lossy().len();
assert!(
bytes > chars,
"fixture must actually be multibyte: {chars} chars, {bytes} bytes"
);
let out = run_guarded(root.path());
let err = String::from_utf8_lossy(&out.stderr).into_owned();
assert!(
!out.status.success() && err.contains("too long for a unix socket path"),
"a multibyte root over the BYTE budget must be refused — it is \
{chars} characters but {bytes} bytes; stderr:\n{err}"
);
}
/// A root of exactly `total` bytes, so the boundary can be aimed at.
fn long_root(total: usize) -> tempfile::TempDir {
let base = short_root_base();
// `<base>/<prefix>XXXXXX`
let fixed = base.display().to_string().len() + 1 + 6;
let pad = total.saturating_sub(fixed);
tempfile::Builder::new()
.prefix(&"a".repeat(pad))
.tempdir_in(&base)
.expect("sized root")
}
/// Run the gate with the ancestor escape set (so only the LENGTH guard
/// can speak) and no ambient `TMPDIR`.
fn run_guarded(root: &Path) -> std::process::Output {
std::process::Command::new(gate())
.arg("--self-test")
.current_dir(repo_root())
.env("PMACS_GATE_TARGET_ROOT", root)
.env("PMACS_GATE_ALLOW_ANCESTOR_MARKER", "1")
.env_remove("TMPDIR")
.output()
.expect("run gate")
}
/// Entries left under `root/tmp` and any `gate-ambient` leaf.
fn walkdir_shallow(root: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
for sub in ["tmp"] {
if let Ok(rd) = std::fs::read_dir(root.join(sub)) {
out.extend(rd.filter_map(Result::ok).map(|e| e.path()));
}
}
out
}
/// The seam handoff §3 keeps authority over: a script cannot infer /// The seam handoff §3 keeps authority over: a script cannot infer
/// which acceptance suites a change touched, so it runs what it is /// which acceptance suites a change touched, so it runs what it is
/// handed — each one, in order. /// handed — each one, in order.