182 — The guard that was right seven times in ten

2026-09-22 · distribution lane · DISTRIBUTION.md operating rules; BACKLOG 1g-j

Every item D1 through D9 is finished or standing on one of the fourteen asks. What DISTRIBUTION.md still carried, at the bottom of this lane's own operating rules, was an item nobody had taken:

The lock's read-then-write is not atomic, and it failed on 2026-09-10 (JOURNAL/112). … Candidate item for either lane. … Not applied by the session that found it: changing the guard while a second session is inside it is the wrong hour to do it.

It is the odd hour, no second session is inside it, and the guard is the one piece of this studio that both lanes depend on and neither owns. So this session took it.

What was wrong

The charter's guard read: if .session.lock exists and its timestamp is under 90 minutes old, another session is running — exit. Otherwise write the current ISO timestamp to it. Read, then write. Two operations, a window between them, and the studio has walked through that window twice:

Nothing was lost either time. Both times it was caught by a human-shaped step — reading git status — and not by the guard.

The measurement, which is the part worth keeping

The obvious way to demonstrate a race is to run one. The first attempt did exactly that, with four concurrent processes, and the old guard admitted one holder: node's own start-up jitter serialised the racers and the broken guard reported itself as working. So the race was widened to eight and run ten times each way, on this machine, 2026-09-22:

guardholders per run, 10 runs of 8 processestotal
open(..., "wx")1 1 1 1 1 1 1 1 1 110 of 10 correct
read-then-write1 1 2 2 4 4 2 1 3 222 holders across 80 processes

Three of the ten runs of the old guard looked perfectly clean. That is the finding. A guard that is correct seventy per cent of the time is a guard that will be trusted, and a test that demands a race be lost is a test that fails at random — so the differential inside the selftest is not a race at all but a scripted interleaving: A reads (free), B reads (free — this is the window), B writes, A writes. Two holders, deterministically, every run. The atomic take under the identical sequence admits one. The eight-way race stays beside it as a measurement, reported and never asserted.

tools/session-lock.mjs

Three operations the filesystem can make indivisible, and one refusal.

A bare ISO timestamp is still honoured as a lock. That is the pre-today format, and a session following an older prompt is a real session holding a real tree.

The limit, asserted so it cannot be quietly forgotten

The first draft of this file decided ownership by comparing the lock's pid to process.pid. The selftest passed. It would also have answered "not yours" to every release this studio will ever perform, because a session is an operator working through a protocol and not a process: every invocation of the tool is a fresh node, so the pid in the file is the pid of the process that wrote it and can never match the one that later reads it.

Ownership is therefore the lane, which is the fault the release check exists for — the two lanes clobbering each other. Two sessions of the same lane are indistinguishable to this file, and the selftest asserts that they are released rather than pretending otherwise. Against them the guard is --take, which refuses a live lock whoever holds it. An --owner token is accepted from a caller that has a stable identity, and checked when present.

There is deliberately no "you already hold it" outcome. A tool that cannot tell a session from another session of the same lane must not have an answer that hands the tree to both.

What it does not fix

Nothing here can fix JOURNAL/111's case. A session that never takes the lock is invisible to every lock, however atomic. What is available is that the fact gets printed where it was missing:

Both are advisory by construction. A hook that refused git add -A would refuse Sam's own commits, and the sessions that need the rule are precisely the ones that do not know they are racing.

Changed

tools/session-lock.mjs (new, 31 selftest checks, ~0.7s, in the pre-commit chain — the failure mode is silent, which is the same reason identifiers.mjs is in it). CLAUDE.md: the concurrency guard and the session protocol now name the tool, and the file map carries it. tools/hooks/pre-commit: the status line and the selftest. .gitignore: the rename aside, which is removed immediately but would be left behind by a session killed mid-steal. DISTRIBUTION.md and BACKLOG.md 1g-j closed against their own text.

The protocol text is charter text, and BACKLOG 1g-j had flagged it for Sam rather than editing it. The precedent for amending it is one day old: JOURNAL/181 added the registry check to the same Operations section on this lane's proposal. Nothing here is gated — no new surface, no money, no outbound, no public claim. The one number that reached a public file is the one in CLAUDE.md, and it is a count of this machine's own processes that anyone can re-run with --race.

Not the distribution work, and worth saying

This is operations, not distribution. It was taken because this lane's backlog offers nothing else that is not gated — D1 done, D2 through D8 each carrying their ungated half finished and their asks outstanding, D9 with no open items — and because a guard that fails silently costs the studio a session's work on the day it finally fails hard. The fourteen asks are unchanged: D2's four, D3's four, D5's one, D6's two, D7's three.