0069 — The row fence gets a warm resident client, and the hooked write path stops paying a cold connect

The problem: the verdict is correct and arrives too late to be heard

A lane that holds a board row must pass every external write through the row fence. On a loaded box the fence refuses legitimate writes — and it refuses them fail-closed, which is the design working, not a safety hole. No write has gone through unfenced.

What is broken is availability. Measured 2026-10-18 on the mini (hw.ncpu=14), with the helper invoked directly, no hook involved:

measurement wall verdict
control A — bare interpreter, same venv, ×3 0.04 / 0.05 / 0.08s —
control B — import agent_smith.row_fence_cli, ×2 3.53 / 4.67s —
check at the helper's own default bound 10.73s refused (fence_unreadable)
check at SMITH_ROW_FENCE_TIMEOUT=60 20.23s ALLOWED (rc=0)

The claim was valid and the door could not be opened in time to hear so. Control A is what makes this specific rather than "the box is slow": a bare interpreter of the same venv starts in 0.04s, so the host schedules processes fine. Of the 20.23s run, total CPU is 1.4s — roughly 19s is waiting.

The helper's bound is three constants, not a tunable:

row_fence_cli.py:130  HOOK_CHILD_DEADLINE_S = 6.5
row_fence_cli.py:136  VERDICT_MARGIN_S      = 1.0
row_fence_cli.py:193  DEFAULT_TIMEOUT = HOOK_CHILD_DEADLINE_S - VERDICT_MARGIN_S   # 5.5s

(cited at agent-smith@2352f91c875a:src/agent_smith/row_fence_cli.py, byte-identical to the copy the hook actually runs — sha256 f245b3386ba4dace… both sides.)

It is a three-way race, not a threshold and not a wall

Two earlier framings of this were wrong and are retracted in the measurement file rather than left standing. The hooked path races three clocks:

So the hook hands ~5s, the import eats ~4.5s, and a connect whose best case is ~0.5s gets 0–2s. A hooked write lands only when a fast import and a fast connect coincide. Load moves two of the three, which is why the outcome tracks load without being a function of it: at 1-min load 27.17 the default bound was met 2 of 2; at 32 and above it was missed 8 of 8. That is a predictor of the odds, not a cutoff — and the row's original reading ("probabilistic, work can land on a retry") was right.

The load-independent part is the import profile. python -X importtime attributes 1.851s cumulative to row_fence_cli: asyncio 1.255s (of which asyncio.base_events 1.019s), argparse 0.356s, agent_smith.row_fence 0.194s — in a CLI the hook calls with fixed arguments.

Why no constant fixes it — written down by the module's own author

row_fence_cli.py:186-192, above DEFAULT_TIMEOUT:

⚠️ AND THIS IS A MITIGATION, NOT THE FIX. It widens the band of loads a legitimate push survives; it does not stop the verdict depending on a cold connect finishing inside a fixed bound, because no constant can. The shape that does is a warm or persistent client so the push path never pays the import at all — a new long-lived process on a shared box, with a SYSTEMS.md entry and liveness signals, which is its own row and its own decision.

One constant re-size has already been spent. The author left the remedy as a decision; this is that decision.

What the title gets wrong, and what the gateways really do

The row's title blames the Temporal connect inside a "4s budget". Both halves are loose. The budget is 3763–6001ms, not 4s, and the connect is only one of the three racers. The row's prose carries 5.5s while its title says 4s for exactly this reason, and the ADR is the place that reconciles them.

And the gateways are not fast — they are UNBOUNDED. One gateway read, driver-workflow assigned, returned rc=0 in 85.39s at the same load. updater, pr-merge and bin/docs add reach a verdict because nothing kills them at 5.5s, not because the connect is quick for them. This matters operationally: it is why a dock can still close rows (at 1–5 minutes a call) while every hooked fenced verb — git push, gh pr create, gh pr merge, gh issue edit — is dead at load ≥30.

The decision

  1. Build a warm resident fence client so the hooked write path never pays the import or the cold connect. The hook asks a process that is already connected; the per-invocation asyncio/argparse/SDK import disappears from the critical path.

  2. Cache the CONNECTION, never the VERDICT. A cached verdict is a lease decision served after the lease may have moved, which is the fencing generation defeated by its own accelerator. Every ask still reaches the lease.

  3. Client-down REFUSES; it never falls through. The three states rule 15 of guard-bash keeps apart must survive exactly, named as the guard names them:

    • NOT ROW-BOUND (exit 0) — no descriptor resolves, so there is nothing to fence: ALLOWED, silently. This is almost every session on the machine, and refusing it would stop every git push in the fleet.
    • REFUSED (exit 3) — the lease said no (stale generation, different holder, expired lease, an item this lane does not hold): DENY.
    • COULD NOT READ (exit 1) — the descriptor or the lease could not be read or asked: DENY, because "an error reading the fence is not permission to proceed".

    And the two states that are not verdicts must keep their current handling: CUT OFF MID-ASK (the helper printed row-fence: asking — and produced no verdict) DENIES with a retry; NEVER RAN (no row-fence: line at all) denies when the lane's descriptor visibly exists and otherwise fails open with a loud warning — decision f2a1da9e1, whose reasoning is that "the instrument is broken" is not "the answer is no", and that the populations are not comparable. An unreachable resident client is COULD NOT READ, not NOT ROW-BOUND. Getting that wrong turns "fence down" into an unfenced write, which the receipt names as the severe arm of its cost-if-wrong.

  4. One spelling of the fence. The resident client answers from row_fence.gate(), the same code path the gateways use. "A second spelling of a fence is a second fence, and the two would disagree on the day it mattered."

  5. It enters the catalog with liveness signals (ADR-0009), and its entry stays proposed with no signals until something actually runs.

  6. Loading the daemon is a human act, in its own PR.

Precedent, so this is not a new shape: ADR-0034's pacer is a launchd daemon called from guard-bash rule 28, so hook → resident-process is established here. ADR-0054 forbids daemon-hosting a fleet session; a service daemon that answers a question is not a session, so it does not bind — but read that ADR before implementing, because it is the nearest rule and the distinction is the whole reason this is allowed. The decider found no contrary ruling in the ledger.

What this ADR deliberately does not do

What is not settled

Amendment — 2026-11-03: built, not loaded

The board row row-fence-answers-in-budget (dock send-door-registry-derived) built this decision, on decider receipt fd0bcfac7 (Fable, high), which re-confirmed that ADR-0069 had already chosen the build and ruled: amend this ADR and the existing catalog entry rather than writing a second of either; loading stays a human act; and an unreachable or slow client answers exit 1 could not read, never allowed, on one path with no fallback.

What was measured first (2026-11-03, load 81-93 on 14 cores): a cold row-bound check cost ~0.4s of CPU (the SDK import plus a fresh TLS connect), stretched to 2.06-5.70s of wall time against the 5.5s bound, and 1 run in 5 was refused at load ~65. Through the resident client, the same asks took 0.32-1.30s end to end, and all of them were allowed. What is left of a warm ask is the lease update itself. Temporal Cloud was not the slow part: an RPC on an already-connected client took 0.08-0.17s.

What is built, and how it keeps each decision:

The one human step. It loads the job and points rule 15 at the asker. The job is loaded FIRST, so the hook never asks a socket that is not there yet:

sed -e "s#__VENV_PYTHON__#$HOME/code/PropFlow/agent-smith/.venv/bin/python#g" -e "s#__SMITH_HOME__#$HOME/code/PropFlow/agent-smith#g" -e "s#__HOME__#$HOME#g" -e "s#__PATH__#$HOME/.local/bin:/opt/homebrew/bin:/usr/bin:/bin#g" ~/code/PropFlow/agent-smith/deploy/co.propflow.row-fence-client.plist > ~/Library/LaunchAgents/co.propflow.row-fence-client.plist && launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/co.propflow.row-fence-client.plist && sed -i '' -e 's#-m agent_smith\.row_fence_cli #-m agent_harness.row_fence_client #' -e 's#/src/agent_smith/row_fence_cli\.py"#/src/agent_harness/row_fence_client.py"#' ~/.claude/hooks/guard-bash.sh

Rollback, also one step. It reverts rule 15 FIRST, because a row-bound hooked write would otherwise be refused "could not ask" while the job is gone:

sed -i '' -e 's#-m agent_harness\.row_fence_client #-m agent_smith.row_fence_cli #' -e 's#/src/agent_harness/row_fence_client\.py"#/src/agent_smith/row_fence_cli.py"#' ~/.claude/hooks/guard-bash.sh && launchctl bootout gui/$(id -u)/co.propflow.row-fence-client && rm ~/Library/LaunchAgents/co.propflow.row-fence-client.plist

Each sed matches exactly one line of guard-bash.sh at the time of writing (the -m invocation and the _fence_mod path). Applying the flip and then the rollback to a copy gives back a byte-identical file, and bash -n passes on it.

Still open: (as of 2026-11-03; the next amendment closes the first item)

Amendment — 2026-11-04: part 2 complete, still not loaded

All of part 2 is merged in agent-harness. Loading stays the human step (§6). No agent loaded the job or edited the live hook.