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
it amends this document instead of opening a new one.
- **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
as the ambient root. **A gate run no longer needs a `TMPDIR=`
override.**
@ -296,8 +297,11 @@ from #171 and #215.
ANCESTOR marker — project detection walks upward — so a fresh
subdirectory of `/tmp` inherits `/tmp`'s ancestors and the same stray
`.git`. The directory had to move somewhere the gate already owns.
- **`SUN_LEN` is the constraint that shaped the layout, and the fix's
own gate run found it.** A Unix socket path cannot exceed 108 bytes
- **The socket-path limit shaped the layout, and the fix's own gate run
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,
`$TARGET/gate-tmp/$STAMP-$$`, produced a 114-byte socket path and
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
fails immediately with the path, its length and what to shorten.
**Its reserve is measured, not round**: the longest suffix a fixture
appends is 21 bytes, so 30 leaves ~40% headroom. An earlier
"generous" 45 **fired on the gate's own behaviour tests**, which run
the gate inside the gate and so reach 71 bytes — a guard that rejects
a legitimate configuration fails on every run rather than a rare one.
- **Witnesses (2), each mutation-checked:** `M-G-1` removes the export
→ the propagation row alone; `M-G-2` stops the reaping → the cleanup
row alone. Propagation is observed in a **spawned child** (the
self-test's first step reports its own `$TMPDIR` into its log),
because the gate exporting a variable would only prove the gate can
export a variable.
appends is **33** bytes (`/.tmpXXXXXX/directory-target.sock`), so
**48** leaves ~45% headroom. Two earlier values were wrong in
OPPOSITE directions — a "generous" 45 that fired on the gate's own
behaviour tests, then a 30 that sat **below the real maximum** and
would have passed 76–78-byte paths. The suite now roots its nested
gates at a short base so it can SATISFY the unchanged production
guard rather than be exempted from it.
- **Witnesses, each mutation-checked.** `M-G-1b` keeps the assignment
and removes only `export` → the propagation row; its predecessor
`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
this machine, and the tests it reddened now pass with **no override**.
- **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
invocation gets a directory created fresh by `mktemp -d` under
**`<gate-root>/tmp/`** — the shared gate root, not the per-worktree
target, because a Unix socket path cannot exceed **`SUN_LEN`** (108
bytes) and the suites bind sockets inside `TMPDIR`. It is exported
target, because a Unix socket path cannot exceed `sun_path` and the
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
reaped by the same exit trap as the ambient root. **A gate run no
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
independent corroboration rather than a repeat of the same argument.
**AND ONE THING IT INTRODUCES, NAMED RATHER THAN DISMISSED — the
observing lane is not environmentally neutral even though it is
code-neutral.** That lane moves the gate's `TMPDIR` off `/tmp`, which
on this machine changes the filesystem underneath every
`tempfile::tempdir()` in the run **from tmpfs to btrfs**. This test
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**.
**The observing lane is environmentally non-neutral, and that is
recorded as a CHANGE rather than as a mechanism.** It moves the gate's
`TMPDIR` off `/tmp`, which on this machine puts every
`tempfile::tempdir()` in the run on btrfs instead of tmpfs. Noted so a
later occurrence can compare like with like.
It is not established either: the failing I/O is on a `UnixStream::pair`,
not on the tempdir path, and the tempdir is created but never bound.
**What this row must not do is book the occurrence as "the usual flake"
when the observing lane changed the very conditions the flake is
sensitive to.**
**A causal claim built on that was advanced here and is WITHDRAWN.**
The draft argued the test's one-second deadline plus a slower
filesystem was a plausible new mechanism. It does not hold on
inspection: **the deadline bounds the connection RETRY loop, not the
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
the same lane's gate run with `TMPDIR` pointed back at a tmpfs — the
one variable this lane moves — rather than another merge-base control.
Three isolated re-runs on the current tree were green, which by this
file's own rule establishes intermittence only.
**So the causal status is unchanged by this occurrence: UNRESOLVED,
with no new mechanism.** What it adds is the corroboration above. Three
isolated re-runs on the current tree were green, which by this 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,
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
marker above it.
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
root 36; the first implementation used the former and produced
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:
103 on Darwin, 107 on Linux. The script takes **103**.
**RULING — a synthetic nested gate does not pay a reserve it never
uses.** The reserve exists for fixtures that bind sockets under
**RULING — a synthetic nested gate is given room to SATISFY the
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
plans are synthetic (`true`, `false`, one `echo`) and which bind no
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
ancestor escape.
**The guard therefore keeps the true maximum for real runs**, and the
tests stop paying for a hazard they cannot encounter. If a future
behaviour row *does* bind a socket, it must move off the short base and
take the reserve with it.
**The guard therefore keeps the true maximum for every run**, and the
suite simply stops handing it a root that cannot clear it. An earlier
draft of this section said nested gates "do not pay" the reserve, which
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.**
`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
# is witnessed by a test that deliberately does NOT set this and
# 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
for _anc in $(
_p="$GATE_TMPDIR"
while [ "$_p" != "/" ] && [ -n "$_p" ]; do
printf '%s\n' "$_p"
_p=$(dirname "$_p")
done
printf '/\n'
); 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 --- most
# importantly a `.git` FILE, which is exactly what a git WORKTREE
# has. Every worktree in this repo would have tripped it.
# CANONICAL, AND QUOTED. Two defects an obvious loop has:
#
# * `for _anc in $(...)` WORD-SPLITS on IFS, so a gate root containing
# a space is torn into fragments and the real ancestor is never
# tested --- the check would pass on exactly the path it should
# reject.
# * `dirname` walks LEXICAL ancestry. `detect_project` canonicalizes,
# so a symlinked root can hide a marker the editor plainly sees.
# Resolving first makes the two agree.
_anc=$(cd "$GATE_TMPDIR" 2>/dev/null && pwd -P) || _anc="$GATE_TMPDIR"
while :; do
for _m in Cargo.toml .luarc.json pyproject.toml go.mod deno.json \
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
echo "gate: a project marker sits above the gate TMPDIR:" >&2
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
gate_marker_refusal "$_anc/$_m"
fi
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
echo "gate: a project marker sits above the gate TMPDIR:" >&2
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
gate_marker_refusal "$_anc/.git"
fi
[ "$_anc" = "/" ] && break
_anc=$(dirname "$_anc")
done
fi

View File

@ -694,7 +694,21 @@ fn the_ancestor_check_honours_marker_types() {
);
// 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");
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");
let out = run_it();
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
/// which acceptance suites a change touched, so it runs what it is
/// handed — each one, in order.