Skip to content

feat(hooks): price the punt — a row this branch filed must have been groomed (CLOUD-514) - #523

Merged
wenzowski merged 3 commits into
mainfrom
claude/groom-cloud-491-qv83e7
Aug 19, 2026
Merged

wenzowski merged 3 commits into
mainfrom
claude/groom-cloud-491-qv83e7

Conversation

@wenzowski

Copy link
Copy Markdown
Contributor

CLOUD-514 phase 2: the gate the sensor was shipped to make specifiable

board-write-record landed in #399 (corrected in #418) as the sensor half and
gates nothing. Shipping a sensor alone is normally the "log without a gate"
non-negotiable 2 refuses; CLOUD-514 argued for a conditional exception — the
gate's firing rate is not computable until the record exists — and named the gate
as what follows. The issue was then closed at the halfway point, so what landed
was the exception without the condition. This is the half that was owed.

What it does

mise-tasks/filed-here-check reads the recorder's own file under $GIT_DIR,
keyed to the branch, and refuses when a row this branch created carries a
stored ready-lint verdict of unready. land calls it beside deferral-check
and stops the lap the same way: that gate prices a decision left with no home,
this one prices a home opened instead of a fix.

The arithmetic it reverses: every other gate here prices failing to record
something, so filing satisfies all of them in seconds while finishing costs a
diff, a suite and a landing. Making the new row cost a complete Ready block flips
that, without anything judging whether a given spin-off was lazy.

  • Three states, not two. ready passes, unready refuses, and - — the
    recorder could not lint, because ready-lint exited 2 or could not run at all —
    passes. Reading "not answered" as "refused" would turn a verdict about the
    environment into a verdict about the row.
  • Comments are recorded and never gated. A comment on the row that already
    owns a finding is sink 2 and the honest common case; pricing it pushes the
    pressure toward silence, which is the failure finding-sink-check exists to
    catch. Friction belongs on the impulsive path only.
  • No content judgement, no tracker read. No similarity comparison and no
    quality score, per non-negotiable 3. The verdict it compares was minted by the
    recorder over the tracker's own response to the create, which is what keeps it
    unforgeable by the author — ready-lint over a caller-assembled payload was
    measured green three times against text in a local file, once under an id no row
    carried.
  • Fails open outside a checkout, on a detached HEAD, and on an absent or
    unreadable record. A branch predating the recorder can never have one.
  • Pointer-only (non-negotiable 4): the refusal names the id and nothing else.

The acceptance bullet that was replaced, not filled

The issue promised four numbers from an observation window over the record. That
promise is unsatisfiable and is now removed from the body: the store lives under
$GIT_DIR, is never committed, and dies with the container, so no fleet-wide
corpus can accumulate and none ever could. Measured on this clone: one file, nine
rows, all from the session that wrote the recorder — zero creates, nine comments.

What replaces it is a structural bound rather than a number: the gate reads one
branch's record inside the session that wrote it, judges only creates, and fails
open on absence, so a wrong verdict costs one lap on one branch and its remedy is
in the refusal message.

Tests

  • tests/filed-here-check.bats — 16 rows, both directions per CLOUD-418: an
    unrefined create stops the lap and the same row passes once refined; a comment is
    never gated; - is not a refusal; the fix-with-no-board-write path is untouched;
    slash-bearing branch names resolve to the recorder's spelling.
  • Three #MUTANT rows, and filed-here-check added to MUTANT_GATES.
  • tests/land.bats gains the stop and its counter bump (28 → 29); 127/127.

Dependency, named rather than fixed here

CLOUD-513 is still Todo and its fix is still absent from main — no
[tasks."test:bats".env] block, zero BATTEN_*_BYPASS references. That is the
reason a suite can go green with its guard disabled, which is how an earlier run
of this work was mis-measured. It is out of scope for this branch and enters
through its own plan.

Closes CLOUD-514

@linear-code

linear-code Bot commented Aug 19, 2026 •

Copy link
Copy Markdown
CLOUD-514 Nothing prices filing over fixing, so spinning off a defect in the PR's own diff is arithmetically cheaper than finishing it

Why

Every gate in this repo prices failing to record something. finding-sink-check fails a turn that cites path:line evidence and makes no durable write. deferral-check fails a PR that defers a decision without naming an issue. stop-guard kicks a hedged flag. issue-guard refuses a PR that names no issue at all.

Nothing anywhere prices the opposite: recording something instead of doing it. Filing satisfies every one of those gates at once and costs a few seconds, while finishing costs a diff, a suite and a landing. For an agent under pressure that is not a temptation, it is arithmetic — and the board becomes the escape hatch every guardrail points at. AGENTS.md already names the behaviour: "A punt is any deferral you could have closed … offering an action you are already authorized to take." That rule is prose, and prose is feedforward only.

Nor is the substitution a fair trade. Across studies of admitted technical debt only 26.3–63.5% of it is ever removed, with median lifespans of 18–172 days and instances surviving more than ten years; in trackers specifically the repayment distribution is severely skewed, median 25 hours against a mean of 872 hours. A ~35× median/mean gap is the signature of a long tail never repaid at all. Filing does not defer a fix, it converts one into a weighted coin-flip.

Measured 2026-08-13, PR #390. CLOUD-513 is a defect in code written in that PR: two new fixture suites read ambient git config, passed verify locally and failed CI. The two suites were repaired in 4259045. The gate — a one-line [tasks."test:bats".env] declaration, in a file the same PR was already editing, whose cost had already been measured at zero (1475/1475 with and without ambient config) — was filed instead of applied. The PR merged green and the board gained a row a one-line edit would have made unnecessary.

No reviewer is present at the moment of the choice, so the cost has to land on the author. Landing here is trunk-based: a branch fast-forwards onto main and review happens after the merge, which is why unreviewed paths stay behind feature flags rather than behind a withheld merge. A mechanism that works by surfacing the punt to a reviewer therefore acts hours or days after the row was opened and the branch was closed. Whatever prices this has to be paid by the author, at the instant of filing, or it is not a price.

Two mechanisms are ruled out before any is proposed

1. Judging the spin-off is forbidden. "Is this issue related enough to the PR to belong in it?" and "should this have been fixed instead?" are both model verdicts, which non-negotiable 3 refuses: a gate resolves to a command and an exit code over an object it decides. CLOUD-505 hit the identical wall, and its resolution is the template — do not judge the content, price the action.

2. A time window is measured, and rejected. The obvious credential-free proxy for "issues this branch filed" is "issues created between the PR opening and its merge". Measured over the last 40 merged PRs against every CLOUD-* created in each window:

merged PRs read:            40
issues created in-window:   184
of those, absent from body: 183   (99.5%)

It fires on essentially everything, and structurally rather than tunably: this repo runs a fleet, so a window captures every session's filings. PR #373's window alone holds 20 issues, none of them its author's. A gate at 99.5% is switched off the first day.

That measurement is what makes a receipt load-bearing rather than convenient. Which branch filed which row has never been recorded anywhere — not in git, not on the PR, not on the board. Without a record the predicate is not merely noisy, it is not computable at all.

Refinement — Ready

Refinement gate: Definition of Ready & Done. This body carries only specializations.

  • Source of truth (§1): one record, minted from the tracker's own bytes. .git/batten-receipts/board-writes.<branch>, written by the PostToolUse recorder, holding per board write: the id, the updatedAt the tracker returned, and the ready-lint verdict over the body the tracker returned.

    Why a local record at all, since a local record is what made the first draft forgeable. A gate running inside land is a shell task, and no task in mise-tasks/ has a tracker credential — that is claim-check's "agents fetch, gates decide" split and it is not negotiable here, because land runs unattended in the background and cannot call a connector mid-lap. So the gate can read only local state.

    But exactly one fact is genuinely unfetchable, and the first draft confused that with three. Which rows this branch put on the board is recorded nowhere — not on the row, not in git, not on the PR. Everything else about a row (is it refined, when was it last touched) is a property of the row itself. The first draft therefore specified a separate ready-lint receipt and leaned on the issue-search receipt as evidence, and both were unnecessary ceremony around the one record that is actually needed.

    And the forgeability was self-inflicted. A ready-lint receipt is worthless when the agent assembles the payload it lints. The recorder does not have that problem: it fires on the tool result, which is the tracker's own response to the create and carries the full issue JSON. Linting that body, and recording the updatedAt beside the verdict, is evidence the author never touched. No second receipt, and no dependency on making a separately-minted verdict trustworthy.

  • Mechanism as a computable predicate (§2): three sinks, priced in ascending order, and chat is not one of them. A finding met mid-PR has exactly three legitimate destinations, and the gate's whole job is to make the cheapest one the one that finishes the work.

    1. Fix it in this PR. Costs a diff. Nothing to record, nothing to gate, and this is deliberately the cheapest path.
    2. A durable comment on the existing row that owns it. Recorded, and otherwise unpriced. The first draft demanded a search receipt naming the target, on the theory that the row should have been found by looking rather than recalled — which buys nothing: an unfiltered list_issues (optional query, limit up to 250) mints a receipt naming 250 rows, and no receipt can tell a right target from a wrong one anyway. Commenting on the row that already owns a finding is the honest common case and the friction belongs elsewhere. Filing remains gated by issue-search-guard, which is where duplicate-prevention lives.
    3. A new row, groomed to Ready. The recorder lints the body the tracker returned; the gate refuses at land time if that verdict was not green. Costs a search (already required by issue-search-guard) plus a complete Ready block: source of truth, computable predicate, effect, output contract, commit type, test obligation, blockers.

    Sink 3 is deliberately more expensive than most fixes, and that is the entire mechanism. A one-line [tasks."test:bats".env] declaration takes minutes; a Ready block for it takes considerably longer. So the arithmetic that currently favours filing reverses, without anything having to judge whether a given spin-off was lazy.

  • Raising the filing bar is measured to work, in both directions (§2). Where a tracker imposes a structured filing template, monthly incoming volume falls while what survives gets better: median resolution time drops from 381 to 103 days, comment counts from 4.95 to 4.32, and more strongly structured templates further reduce resolution time, reopenings and discussion length. Fewer rows and faster ones is the same effect from both ends, and it is why the price is a Ready block rather than an arbitrary toll.

  • The friction must sit only on the impulsive path (§2). Deliberate friction is legitimate where it prevents an impulsive choice and illegitimate where it obstructs something needed. So: sink 2 stays cheap, since commenting on the right row is the common honest case; sink 3 is expensive but never refused, since a genuinely new finding must always be recordable; and every gate here fails open on anything it cannot establish. A gate that made recording a real finding hard would cause the failure finding-sink-check exists to catch.

  • The recording half must be PostToolUse, not PreToolUse (§2). At PreToolUse a created row has no id yet; the tool result carries it. Measured, because no hook in this tree had ever read a tool result and the documented example is a Write with a flat response. For an MCP tool the result is the content-block envelope, so .tool_response.id does not exist — the recorded shape is [{"type":"text","text":"<the issue JSON as a string>"}], and the key is reached with .tool_response[]? | select(.type=="text") | .text | fromjson | .id. The text carries the entire issue body, so the recorder must extract the id and nothing else (non-negotiable 4). .claude/settings.json already runs a PostToolUse entry, so the event is available and the shape is proven. The body records both a create and a comment — a comment is sink 2 and must be attributable to the branch too — appending to .git/batten-receipts/board-writes.<branch>. It reuses issue-search-guard's decided details rather than re-deriving them: the create-vs-update discriminator (.tool_input.id absent) and the suffix-anchored matcher, since CLOUD-178 measured three live spellings of the same connector and a rule naming one silently matches none of the others.

  • The recorder calls ready-lint; ready-lint itself is unchanged (§2). It is already a pure function of a piped payload and already the authority on a Ready block, so the recorder pipes it the tracker's returned body and stores the verdict. A hook shelling out to a task is normally the cost this repo refuses — but that argument was measured against a PreToolUse firing on every Bash call, and this fires only when a row is created, which is rare enough that the startup cost is irrelevant.

  • The gating half is filed-here-check (§2), called from land beside deferral-check and stopping the lap the same way. For every create this branch recorded, the stored ready-lint verdict must be green. Comments are recorded and not gated. It judges no content and reads no tracker.

  • Do not reuse claimed-keys for any body read (§2). It narrows to closing keywords because it answers which issues does this PR claim, and claiming would demand the PR close the spun-off row — precisely wrong for a genuinely unrelated one. CLOUD-379 and CLOUD-384 record the two being conflated in the other direction.

  • Ship the recorder first, then the gate (§2). The firing rate cannot be estimated retrospectively — the discriminating data has never existed, which is the finding above. The recorder landed alone in feat(hooks): record what this branch put on the board, and whether it was refined #399 (corrected in fix(hooks): a comment records the issue key, never the comment's own uuid #418), and that staging is what makes the gate's input a proven shape rather than an imagined one. It does not buy a measured firing rate, and the original wording promising one was wrong: the record lives under $GIT_DIR, is never committed and dies with the container, so no corpus accumulates across sessions and none ever could.

  • Effect (§3). read. A PostToolUse body writing under $GIT_DIR — machinery, as claim-guard's receipt already is — plus a task reading stdin and receipts, plus one receipt write added to an existing task. No new batten verb and no SURFACE change.

  • Output & exit contract (§5). deferral-check's, unchanged: non-zero stops the lap, pointer-only per non-negotiable 4 — the ids and the missing receipt, never an issue title or body. The recorder is silent on every path and fails open on an unparseable payload, a missing git dir or a detached HEAD.

  • Commit / bump (§6). feat(hooks) — patch until 0.1.0 regardless of type.

  • Test obligation (§7). One suite per gate, because mutant derives its suite as tests/$gate.bats; #MUTANT rows in each and every name added to MUTANT_GATES.

    Shown able to fail in both directions (CLOUD-418): a recorded create with no ready-lint receipt fails, and passes once the row is refined; a create whose recorded verdict is green passes; a recorded comment is never gated. Plus the rows that keep it from obstructing the needed path — a branch with no recorder output at all passes, an empty record passes, an update to an existing row is never recorded, and a fix-with-no-board-write passes untouched, which is the path the design wants to be cheapest.

    The regression case is this session: CLOUD-513 recorded against PR fix: the fast-forward verdict reaches land, and filing joins the gated paths #390 with no Ready receipt at filing time is refused, and passes once groomed.

  • Blockers (§8). None. relatedTo CLOUD-512 — it gates the move to In Review and explicitly excludes the attachment gap this closes as a side effect; CLOUD-323 and CLOUD-338 — deferral-check, the template and the record of its exemption logic being got wrong; CLOUD-505 — the search receipt this consumes; CLOUD-475 — finding-sink-check, the gate that prices under-recording and the reason nothing prices over-recording; CLOUD-513 — the measured instance.

Chat is not a fourth sink, and that is a memory property rather than a style rule

A finding stated in prose and never written durably is context-resident state. Long-horizon agents lose exactly that: unbounded context forces truncation and summarisation, attentional dilution degrades recall of any single item as context grows, and material in the middle of a long context is recalled least reliably — which is why an external store holding raw records at full fidelity is the standing recommendation. So a finding left in an aside is not merely undocumented, it is predictably lost, and the container reclaim this session survived is only the abrupt version of the same thing. finding-sink-check already gates this and needs no change here; it is named so the three sinks above are not read as four.

How this gets satisfied without doing the work

Three, found by attacking the mechanism rather than reasoning about it, each with local evidence. Two are closed above by changing the design; the third is a residual, stated with its failure direction.

A ready-lint verdict is forgeable if the agent supplies the payload — closed by moving the lint into the recorder, which lints the tracker's response instead. The finding stands and is why §1 reads as it does; the original remedy (a second receipt, made trustworthy elsewhere) was the wrong fix for it.

The measurement that made the hole real. ready-lint reads a payload the caller assembles. Run against this very issue three times while it was being refined — twice from a local file, once under the literal id CLOUD-NEW for a row that did not exist — it was green every time. So a receipt would attest that some text linted clean under some id, while the filed row is a stub. A toll payable in text nobody filed is not a toll. The first remedy drafted for this was a second receipt made trustworthy elsewhere, which added a dependency instead of removing the hole; moving the lint into the recorder — where the input is the tracker's response rather than anything the author typed — removes it. The same forgery argument is made independently by CLOUD-431 for its own question, which is why that issue is worth reading beside this one.

An unfiltered listing launders sink 2 — closed by dropping the requirement, because it bought nothing. list_issues takes an optional query and a limit up to 250, so a receipt demanding that the comment target be named is satisfied by one no-query call. Nor could any receipt tell a right target from a wrong one. Sink 2 is now recorded and unpriced.

The record survives a branch restart — and that is fail-closed here, which is why it is a residual and not a blocker. claim.<branch> on the branch this issue was written on names CLOUD-230, a claim from an earlier incarnation of the same branch name that outlived two checkout -B … origin/main restarts. The record proposed here is keyed the same way, so it inherits the staleness — but not the failure direction, and that distinction was missed on the first pass. There the defect is a false pass: claim-guard waved through every edit all session on expired evidence. Here a stale entry can only add rows the gate insists on checking; it can never remove one, since a restart does not delete the record and the new branch's own creates are appended fresh. So the worst case is a refusal naming a row from a previous incarnation, whose remedy the refusal message already gives. A gate that fails closed on stale state is the outcome this repo prefers, so the dependency was withdrawn. CLOUD-516 fixes the underlying keying and is worth landing first for its own reasons, but nothing here waits on it.

What this cannot do

It cannot judge the reason, and a minimal-compliance path still exists — a Ready block written to satisfy ready-lint rather than to be worked. The gate cannot refuse that without scoring prose, which is the model verdict rule 3 forbids. But the floor is now a complete Ready block rather than a pasted key, so the cheapest way to satisfy it is close to the work the row actually needs. Re-open predicate: re-open if a spun-off row is found to carry a Ready block written to pass ready-lint rather than to be worked — observable per instance on the row itself, which is the only scope available, since no cross-session window exists.

Gating creates pushes pressure toward commenting instead, and that is accepted rather than fixed. A comment on the row that already owns a finding is a legitimate durable home — cheaper than filing by design, since the friction is meant to sit on the impulsive path and not on honest recording. What makes it acceptable is that the pressure runs toward recording in the right place, not toward silence. It is still recorded, so the ratio is observable: CLOUD-475 records that a comment already counts as a durable home. Re-open predicate: re-open if a branch's own record shows comments standing in for fixes to defects in that branch's own diff — readable per branch at land time, which is the only scope available, since no cross-session window exists.

Acceptance

  • A row created while a branch was live is refined at creation, on the evidence of the tracker's own response, and the lap stops otherwise.
  • Fixing costs less than filing, and the acceptance is arithmetic rather than opinion: the cheapest path through the gate for a defect in the branch's own diff is to fix it.
  • A genuinely new finding is never refused a durable home, and a comment on the right existing row stays cheap.
  • No similarity comparison and no quality score exists anywhere in the mechanism.
  • The gate's deployment safety is structural, not measured, because the observation window this bullet originally promised is unsatisfiable. Verified 2026-08-19: .git/batten-receipts/board-writes.* is one file, nine rows, all from the session that wrote the recorder — zero creates, nine comments. The store is per-clone, never committed, and reclaimed with the container, so there is no fleet-wide window and there never was one. What stands in its place is the gate's own scope: it reads one branch's record inside the session that wrote it, judges only creates, and fails open on an absent or unreadable record — so a wrong verdict costs one lap on one branch, and its remedy is in the refusal message.

Not in this issue

Deciding whether a given spin-off was legitimate — the judgement the gate must never make. The In Review transition gate, which is CLOUD-512's. And retrofitting receipts for branches predating the recorder, which is why the gate fails open on their absence.

CLOUD-491 A live `plan-hold` did not stop a container restart, and nothing records that it was live — the hold ships with no sensor

Why

CLOUD-451 landed plan-hold on the premise that "the container is idle, so it is reclaimed" — so occupying the container with a backgrounded no-op defers the reclaim. Its acceptance is "a plan left open for longer than the observed reclaim window survives, with the human's typed input intact."

Measured 2026-08-12, ~22:12 UTC. A hold was armed (mise run plan-hold, backgrounded, task bfu2m1iit) and the container was restarted anyway. The harness's own restart notice named the four tracked tasks it killed:

- Wait for verify to finish (baf3psi7z)
- Wait for local CI reproduction to finish (bcbzgem2e)
- Re-commit with the message file and land (bge0bb5ib)
- Arm plan hold before exiting plan mode (bfu2m1iit)

So the container went down with four live tracked tasks, one of them a full land mid-CI-wait. The new container came up at 1 minute uptime with 14.6 GB of 16 GB free and 24 GB of disk available, and no OOM line reachable — the restart was not resource pressure on this side. The human's approval button was gone, which is the exact damage CLOUD-451 exists to prevent, and the landing work survived only because it had been committed.

Reproduced 2026-08-12, ~23:45 UTC, in the session grooming this issue: a hold was armed, and the container was restarted roughly a minute later, killing it. Two independent instances now, and the second one left artifacts the first did not — see the measurements below.

The finding is not "the hold is wrong". It is that the hold cannot be graded. A mechanism that occupies the container leaves no record of having done so, so from inside there is no way to distinguish the hold having been live and reclaimed anyway from the hold having already exited from a platform event no occupancy could defer. All of them produce the same observation — a fresh container — and the issue's acceptance is written in terms nobody can check after the fact. That is the sensor-without-a-gate shape inverted: a mechanism with no sensor. It is why an instance can be reported but not diagnosed, and why the honest first move is evidence rather than a bigger hammer.


Measured 2026-08-12 ~23:30–23:46, and it changes the mechanism

The first draft of this issue assumed one kind of container replacement. There are two, they are indistinguishable from inside without a sensor, and they have opposite consequences for any sensor built from a local file.

1. The 23:30 boot destroyed everything. btime was 1786577404 (23:30:04Z), and

find /home /root /tmp /var/tmp -xdev -newermt "21:00" ! -newermt "23:29"  →  0 files

A session was demonstrably alive at 23:28 — this issue's own last edit — and nothing it wrote survived anywhere writable. .git/index was dated 23:30:11, seven seconds after boot, sitting over an image base layer from 2026-08-06. A fresh container on a fresh disk: the clone is reconstructed at boot.

2. The 23:45 boot preserved the disk. btime 1786578317 (23:45:17Z), and 20,184 files from the previous container survived — target/, and .git/batten-holds/ itself. A restart of the same disk.

Same appearance from inside, opposite consequences. A heartbeat file under $GIT_DIR is readable across (2) and does not exist at all across (1) — so the three-case taxonomy this issue shipped with has no room for the second event class, and its predicate last_heartbeat > btime - poll cannot tell a fresh container from no hold was ever armed. Both present as an absent file, which the draft defined as silence. Shipped as written, the sensor would pass a bats suite that fakes boot time in a tmpdir and report nothing in the case that actually destroyed the plan.

3. The surviving hold directory was empty. .git/batten-holds/ came through the 23:45 restart present and empty, mtime 23:42:15, with a hold armed shortly before. The sentinel is gone. plan-hold-check's header asserts "a container reclaim does not run traps", and this artifact was at first read as consistent with its negation — the hold's own trap erasing the only record that a hold was live. Measurement 4 supersedes that reading: 23:42:15 is a boundary, not a trap firing. The trap remains a candidate and the heartbeat must still not be trap-removed, but it is no longer the leading explanation.

4. The last 182 seconds of writes did not survive, and this falsifies the predicate this issue shipped with. Every surviving pre-boot write stops at 23:42:15 — the MCP logs of four different servers, Serena's log, target/.rustc_info.json, and .git/batten-holds — while the container demonstrably kept serving tool calls until ~23:45. From the newest surviving file to btime: gap=182s. Whether the cause is a disk snapshot boundary or unflushed page cache cannot be determined from inside, and it does not need to be: either way the tail of a per-poll heartbeat is lost exactly when the sensor needs it.

So the first draft's predicate — epoch ≥ current btime − poll, a 5 s tolerance — reads the surviving last heartbeat as three minutes stale and answers "did not span" for the very event it exists to catch. Widening the tolerance to cover 182 s does not rescue it: a window that large also swallows a hold that legitimately exited minutes earlier, which collapses the already-exited case and with it the point of the sensor. The predicate has to stop being a time comparison.

The heartbeat question, stated so it can be settled rather than argued. Two different things are called a heartbeat here and only one is cheap:

  • A heartbeat as EVIDENCE — the hold records its liveness to an out-of-tree file each poll. Costs one date and one append per 5 s, no model call, no tokens.
  • A heartbeat as KEEP-ALIVE — something that periodically wakes the session, on the theory that the reclaim clock is driven by agent turns rather than container activity. Each wake is a model call, metered in AGENTS.md's most expensive category, and AGENTS.md separately bans timers for babysitting. It must not be adopted on a hunch.

The first is a prerequisite for deciding the second. Ship the sensor, read it, then decide. The activity-versus-existence question — every wait in this repo that survives does I/O, and this one does not — is CLOUD-500, deliberately blocked on this issue for that reason.

What the sensor must distinguish, and how

The hold records why it stopped, not merely when it last ran — absence of an intentional-exit record is the signal, and absence is the one reading robust to losing the tail. Two line kinds in one appended file: h <epoch> <btime> per poll, and x <epoch> <btime> released|capped on an intentional exit. The verdict reads the last line only.

last line exit case
h, recorded btime ≠ current 0 a hold was live when this container replaced the last one
x 1 the hold ended on purpose — the un-released window of CLOUD-485, or the cap
h, recorded btime = current 1 this boot's own hold; it spanned nothing
absent, a prior boot recorded 1 disk preserved, no hold was ever armed here
absent, no prior boot recorded 2 fresh container — no local evidence is possible, and saying so is the honest answer
unparseable 2 cannot look; never 0

The residual error is bounded and in the conservative direction: a hold released inside the lost-write window reads as "was live".

The residue probe of the first draft does not work either, and is replaced. It separated the last two rows by whether prior-container residue exists, naming target/ and .git/batten-contract. Measured: every batten-* directory mtime is post-boot on a demonstrably preserved disk, because a directory's mtime updates on each add or remove; and target/ is rebuilt by session-start's build:release on a fresh container too. Recursive file mtimes do stratify — 36 files dated 08-12 against 21 from the 08-06 image bake — but separating those strata needs the bake date, which is not a stable predicate. In its place, session-start appends the current btime to $GIT_DIR/batten-boots once per session. A recorded btime other than the current one proves the disk is preserved, which makes the distinction exact rather than heuristic.

Refinement — Ready

  • Source of truth (§1). Two local readings, no network: the hold's own heartbeat file, and the container's boot time (/proc/stat's btime, with uptime -s as fallback). Both are properties of this machine, so the predicate stays a gate rather than a guess. The heartbeat lives at $GIT_DIR/batten-hold-heartbeat — outside batten-holds, because both plan-hold's incumbent-release loop and plan-hold-check live's reaping loop iterate "$dir"/* and delete every file whose first line is not numeric; the <dir>/heartbeat an earlier draft of this clause specified would be eaten on the first check. The path is spelled once, in mise-tasks/plan-hold-check beside hold_dir() — the discipline tests/plan-hold.bats already pins with its "spelled in exactly one place" case, and every other caller asks for it.
  • Mechanism as a computable predicate (§2). plan-hold appends h <epoch> <btime> on every poll it already performs — no new loop, no new interval — and x <epoch> <btime> released|capped on each of its two intentional terminal paths. The x record is written only there, never in the trap … EXIT INT TERM, because that is the entire mechanism: a hold killed by the container going down leaves its last line as an h, and no time comparison is needed to see it. Pairing the boot time into each line is what tells this boot's own hold from a previous one's. The heartbeat is never trap-removed. A new plan-hold-check spanned sub-verb (so the path stays spelled once) resolves the table above from the last line. sync -d follows each append — it cannot defeat a snapshot boundary, but it closes the unflushed-cache case for one syscall per poll. Reported once, as a pointer — the verdict and the recorded epoch, never a plan or a prompt body.
  • Effect (§3). read. One append per poll to a file under $GIT_DIR, which claim-guard treats as machinery rather than work. No new batten verb and no SURFACE change; the new spellings are sub-verbs of plan-hold-check, whose header usage block and dispatcher error string are the one authority for that list. Growth is bounded — 4 h ÷ 5 s ≈ 2,880 lines ≈ 58 KB — and append is chosen over rewriting a single line because a rewrite can tear at exactly the moment that matters.
  • Output & exit contract (§5). The reading is 0 (a hold spanned the restart) / 1 (it did not) / 2 (cannot look), matching plan-hold-check's existing table, with the fresh-container case landing on 2 rather than a false 1. The session-start report is one line, pointer-only, and lands in .claude/hooks/session-start.sh after container-preflight, on a line that never sets fail — a restart verdict must not halt a session. plan-hold itself keeps exit 0 on both terminal paths — it is not a gate and this does not make it one.
  • Commit / bump (§6). fix(plan-hold) — patch until 0.1.0.
  • Test obligation (§7). Mutation-checked per CLOUD-418, which sets the file layout and not merely the rows: mutant derives its suite as tests/$gate.bats, so the predicate is only gateable once plan-hold-check has its own suite. It gets one — tests/plan-hold-check.bats — following the convention claim-check, land-lock-check and stop-posture-check already keep and this task alone breaks; the existing predicate rows migrate into it, which the bats-tests-not-deleted ratchet permits because its scope is the tree. Both plan-hold and plan-hold-check join MUTANT_GATES. A testability constraint: /proc/stat cannot be faked, so the boot-time reader must be injectable (BATTEN_BOOT_TIME, defaulted from /proc/stat) or no row can discriminate and the suite ships as coverage. Fixture rows for every line of the verdict table, plus: an empty file reads as absent; the report emits one line carrying no plan text; plan-hold-check live does not delete the heartbeat — a regression row for the siting collision above; and an unknown verb exits 2, a dispatch arm with no coverage today that this change adds to. Two rows that are not fixtures, because they are what the mechanism claims: a hold kill -9ed leaves an h as its last line even though its trap runs, and a hold released or capped leaves the matching x. The declared mutations — rewriting the x write to emit h, and collapsing the h/x discrimination in the reader — must turn those rows red.
  • Blockers (§8). None. relatedTo CLOUD-500 — the activity-versus-existence hypothesis, which waits on this sensor by design. Relations: CLOUD-451 is the mechanism this grades and its acceptance is the claim in question; CLOUD-485 is the other reason a hold may not be live when it is believed to be, and the two must not be conflated when reading the evidence; CLOUD-425 is the general "a background task cannot be asked what it is doing" gap this is one instance of; CLOUD-388 is the corpus question for any transcript-side reading of the same event, and measurement 1 above is its filesystem instance — a fresh-disk replacement leaves no local corpus either.

Acceptance

  • After a container restart, the session can say whether a hold was live when it happened — from a local reading, without a transcript.
  • The cases are distinguishable, and the report names which. In particular a fresh container is reported as "cannot look", never as "no hold was live".
  • The reading costs no model call and no network.
  • The rows fail when the h/x discrimination is removed.
  • The verdict does not depend on a heartbeat written inside the lost-write window — the property measurement 4 falsified in the first draft.

Measured in the session that also filed CLOUD-488; the plan whose approval button was destroyed was the one grooming CLOUD-427. The 23:30–23:46 measurements and the empty-hold-directory artifact were added in the grooming session, which was itself restarted twice while doing it.

Review in Linear

@coderabbitai

coderabbitai Bot commented Aug 19, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

@wenzowski, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 11 minutes

Limit details: You’ve used all 3 included reviews currently available.

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e2968825-4468-4c21-a8cb-a0ea41add18c

📥 Commits

Reviewing files that changed from the base of the PR and between d255ed3 and 9575991.

📒 Files selected for processing (6)
  • .claude/rules/toolchain.md
  • mise-tasks/filed-here-check
  • mise-tasks/land
  • mise.toml
  • tests/filed-here-check.bats
  • tests/land.bats

Comment @coderabbitai help to get the list of available commands.

@wenzowski
wenzowski force-pushed the claude/groom-cloud-491-qv83e7 branch 2 times, most recently from 9f7bb85 to 29f3e5c Compare August 19, 2026 15:44
…groomed

`board-write-record` landed as the sensor half of CLOUD-514 and gates nothing.
Shipping a sensor alone is normally the "log without a gate" non-negotiable 2
refuses; the exception was conditional on the gate following, and the issue was
closed at the halfway point instead. This is the half that was owed.

`filed-here-check` reads the recorder's own file under `$GIT_DIR`, keyed to the
branch, and refuses when a row this branch CREATED carries a stored `ready-lint`
verdict of `unready`. `land` calls it beside `deferral-check` and stops the lap
the same way: that gate prices a decision left with no home, this one prices a
home opened instead of a fix.

Three states, not two. `ready` passes, `unready` refuses, and `-` — the recorder
could not lint, because `ready-lint` exited 2 or could not run at all — passes.
Reading "not answered" as "refused" would turn a verdict about the environment
into a verdict about the row, the confusion the recorder's own three-way status
read already exists to avoid.

Comments are recorded and never gated. A comment on the row that already owns a
finding is sink 2 and the honest common case; pricing it would push the pressure
toward silence, which is the failure `finding-sink-check` exists to catch.

It judges no content and reads no tracker: no similarity comparison, no quality
score, no tracker credential — `claim-check`'s agents-fetch-gates-decide split.
The verdict it compares was minted by the recorder over the tracker's own
response to the create, which is what keeps it unforgeable by the author.

Fails open on everything it cannot establish — outside a checkout, a detached
HEAD, an absent or unreadable record. A branch predating the recorder has no
file and cannot be given one: the store lives under `$GIT_DIR`, is never
committed, and dies with the container.

The issue's acceptance bullet 5 promised four numbers from an observation window
over that store. It is unsatisfiable and was replaced rather than filled: the
record is per-clone, so no fleet-wide corpus can accumulate. The gate's
deployment safety is structural instead — one branch's record, read inside the
session that wrote it, fail-open on absence.

`tests/filed-here-check.bats` covers both directions (CLOUD-418): an unrefined
create stops the lap and the same row passes once refined, a comment is never
gated, `-` is not a refusal, and the fix-with-no-board-write path is untouched.
Three `#MUTANT` rows, and `land.bats` gains the stop plus its counter bump.

Closes CLOUD-514
…s with

The rule ships with its mechanism: `filed-here-check` lands in the same change,
so the guard list that already carries `deferral-check` carries its sibling —
the two are one pair, one pricing a decision left with no home and the other a
home opened instead of a fix.

Refs: CLOUD-514
`mutant` filters bats cases by substring, so a filter that is not one selects
nothing and the mutation reports names-no-case rather than a caught row — a
declared mutation that proves nothing, which is the failure `mutant` exists to
catch, reached through its own declaration syntax.

Refs: CLOUD-514
@wenzowski
wenzowski marked this pull request as ready for review August 19, 2026 16:19
@wenzowski
wenzowski force-pushed the claude/groom-cloud-491-qv83e7 branch from 29f3e5c to 9575991 Compare August 19, 2026 16:19
@sonarqubecloud

Copy link
Copy Markdown

@wenzowski

Copy link
Copy Markdown
Contributor Author

/fast-forward

@wenzowski
wenzowski merged commit 9575991 into main Aug 19, 2026
10 checks passed
@wenzowski
wenzowski deleted the claude/groom-cloud-491-qv83e7 branch August 19, 2026 16:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant