Skip to content

fix(claim-check): refuse a LIVE pull request, not a merged predecessor (CLOUD-520) - #509

Merged
wenzowski merged 7 commits into
mainfrom
claude/cloud-369-grooming-fswo8x
Aug 19, 2026
Merged

wenzowski merged 7 commits into
mainfrom
claude/cloud-369-grooming-fswo8x

Conversation

@wenzowski

@wenzowski wenzowski commented Aug 19, 2026 •

Copy link
Copy Markdown
Contributor

Why

has-pr's stated purpose is narrower than what it implemented. Its own header says it "catches one that published before the column moved" — a claim about an open pull request. A merged one is the opposite signal: evidence that work finished, not that it is in flight.

So an issue released back to Todo could never be pulled again. Measured on CLOUD-479 — Todo, unassigned, its body explicitly inviting the next taker — refused on PR #376, which had merged the day before. The same shape blocked CLOUD-369 today, whose two attached PRs are both merged.

The constraint that shapes the fix

The state cannot be looked up here. This gate is a pure function of stdin — no tracker credential, no network, so it cannot hang or rate-limit and its suite runs unconditionally — and the tracker's attachment objects carry id, title, subtitle and url, with no state at all (measured on CLOUD-369's payload: two attachments, both merged, neither saying so).

So the caller supplies it, the shape claimed-keys already uses for facts about a PR this checkout did not author. That preserves agents-fetch-gates-decide rather than trading it away, and it is why the rule is not simply demoted to a warning — one that can still refuse a live competitor is worth more than one that only advises.

Absent refuses, and that default is what makes this safe. A caller supplying nothing gets exactly today's behaviour, so the narrowing can only ever turn a false refusal into a pull — never a real competitor into a silent pass. Open refuses, malformed refuses (a parse failure must not become a pass), and only an explicit merged/closed reading stands down. The refusal now names the remedy, since the alternative a caller finds on its own is skipping the gate entirely.

Tests

Nine cases, 44/44 in the suite. The load-bearing ones are the pairs:

  • a merged PR is pullable while the same payload without the state still refuses;
  • an open PR still refuses — a narrowing that also stopped refusing live competitors would pass every positive case and be worthless.

The mutation row took two corrections, both worth naming

It first reported names-no-case: mutant passes the declared case name to bats --filter, which reads it as a regex, so a name containing (a) matched nothing and the row proved nothing. Renamed to clause a.

It then SURVIVED: the row neutered the merged boolean while the state string check still filtered merged PRs out, so the case passed on deliberately broken code. It now neuters the state comparison.

mise run mutant: 42 of 42 declared mutations caught.

That regex trap is worth knowing beyond this PR — clause-reference names like (b1) are the natural way to bind a test to a §7 obligation, and they are exactly the names that silently fail to match. Recorded on CLOUD-472.

Closes CLOUD-520

@linear-code

linear-code Bot commented Aug 19, 2026 •

Copy link
Copy Markdown
CLOUD-369 The second matrix is bought unconditionally: a successor is admitted behind a holder whose CI has not answered, and whose base is known to conflict

Why

Rescoped 2026-08-13, the second time. The first rescope (2026-08-12) cut this issue down to what the landing lease did not do; PR #369 merged that day and the board moved it to In Review. Most of that scope did land — the warm queue and speculative linearization, LAND_LOCK_AGE aging, the head:/next: fields and the reserve verb, the CI-side two-slot authorises, and the voided-run cancel (#364). What did not land is one clause of this issue's own acceptance, plus three gaps measured after the merge. The body below carries only those. The earlier framings are preserved in the comments and are not re-typed here; the successor-rewind hazard is CLOUD-495's and landed separately in #381.

This issue is labelled landed while its own §7 obligation is unmet. That is the shape CLOUD-472 exists to gate: the Ready block is checked for shape, the code for correctness, and nothing compares the two. Verified against origin/main at 195c04c:

Clause On main Where
§7(b1) negative — a holder whose CI is not green admits nobody unlanded mise-tasks/land:1199 reserves the successor the instant the lease is lost, reading no CI at all
A successor whose base is known to conflict is admitted anyway unlanded land:601 already computes the conflict and only suppresses speculation; the reserve at :1199 runs regardless
land-lock-check names the admitted successor unlanded mise-tasks/land-lock-check:124 prints holder and TTL only, though status/peek already serve next:
Mutation rows for the predicates #369 landed absent land:93-95 carries three #MUTANT rows — exit codes, declined, pagination — and none touches reserve, speculate or settle

Why the first two cost money rather than tidiness. The second matrix is a favourable bet because it is conditioned: a holder that is green and holding the lease will almost certainly fast-forward, so the successor's run overlaps a merge that is about to happen. Unconditioned, the same run is bought behind a holder whose CI has not answered and may come back red — in which case the merge never happens, the successor's run is voided, and the mechanism has spent an extra matrix to save nothing. That is the waste this issue exists to remove, reappearing inside its own fix. The conflict arm is the same argument reached from the other side and measured on 2026-08-13 (comment above): the holder landed CLOUD-515, the admitted successor's run on 48d56f3 was void the instant main moved, and the rebase then conflicted delete-versus-add in two files. Ledger for that one admission: one full CI run burned, one ~200s verify discarded, one hand-resolved conflict, and a second run required. A successor whose base is known to conflict is guaranteed to be voided, so its admission is a run that can never pay.

The third is diagnosis: land-lock-check is what a human runs on a wedged lease, and it is the one view that cannot see who is admitted. The fourth is CLOUD-418's discipline applied to work that shipped without it — every predicate #369 landed is shown to pass and never shown to fail.

Acceptance

  • A second matrix is bought only behind a green holder. Admission reads the holder's CI before reserving, and only a green answer admits. The three non-green answers — red, no answer yet, could not look — all decline, and declining costs nothing: the waiter stays rebased and verified locally and reserves on a later lap for the price of one poll.
  • A successor whose base conflicts is not admitted. The conflict land already computes before the run is spent suppresses the reservation, not just the speculation. A successor whose base applies cleanly keeps today's behaviour and today's benefit.
  • The lease's diagnostic names both branches. land-lock-check reports the admitted successor when the lease carries one, so the gate a human runs on a wedged lease shows the whole occupancy rather than half of it.
  • Every predicate this issue landed is shown to fail. Each of the admission, speculation and settlement predicates carries a #MUTANT row whose named case goes RED under mise run mutant.
  • Byte-stable, pointer-only reporting per non-negotiable rule 4: a branch name, a short SHA, a PR number — never a run log and never a diff.

Explicitly rejected

  • A second admission authority. The green condition extends the existing reserve call site in land. A new lock, a new ref, or a dispatcher is the failure mode, not an implementation option.
  • Reading CI from land-lock. The lease uses exactly one operation, git push --force-with-lease, and its suite asserts it never calls gh. A lease that reached for the API would become a second authority for CI state as well as for the lock.
  • Parsing land-lock status' prose. peek head already exists and is the machine-readable read; making a sentence into an interface is how the two drift.
  • A wall-clock timeout or fixed sleep anywhere in the decline path. Ruled out repeatedly: the fix for a race is an exit condition that can fire, never a guessed delay.
  • Cancelling anyone else's run, a dispatcher, or a WIP cap on sessions. Unchanged from the earlier scope.

Refinement — Ready

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

  • Source of truth (§1). mise-tasks/land and mise-tasks/land-lock stay the one authority for admission. checks-green stays the one definition of "is this SHA green" (CLOUD-346), asked once rather than polled. land-lock peek head (land-lock:1129) is the machine-readable read of the holder's head. Where this body and the tasks disagree, the tasks win and this body is stale.
  • Computable predicate (§2). Each criterion resolves to a command and an exit code over an object it decides, never a model verdict (non-negotiable rule 3). Admission is checks-green over the SHA peek head names: exit 0 admits, 1 red / 2 could not look / 3 no answer yet all decline, and an empty head: declines too — the lease naming no head means the holder's CI cannot be read. The conflict arm is decided by the same rebase probe that already prints the not speculating line at land:601. The reporting clause is decided by land-lock-check's output naming the successor when the lease carries one. Mutation coverage is decided by mise run mutant over $MUTANT_GATES.
  • Effect (§3). The holder-CI read is a new read from land, against a SHA it already knows. No new write, no new ref, no new state. land-lock stays git-only.
  • Output & exit (§5). Pointer-only: a branch name, a short SHA, a PR number, never a run log. Exit codes follow the one contract — 0 clean, 1 usage, 2 the policy verdict, 3 unreachable — with no per-verb exception.
  • Commit / bump (§6). fix → patch until 0.1.0 (below 0.1.0 release-plz bumps the patch whatever the type says).
  • Test obligation (§7). bats beside tests/land.bats and tests/land-lock.bats, each case asserting an exit code rather than prose, and each naming the clause it discharges so a future binding gate (CLOUD-472) can match on it:
    • (b1−) a holder whose CI answers red, has not answered, or could not be looked at admits nobody — three cases, one per answer, each asserting the waiter stays in draft and no reservation is written. This is the negative that gives the landed positive its meaning, and its absence is why the obligation was dropped unnoticed.
    • (b1+) the landed positive still holds — a green holder admits exactly one waiter, and a third branch is still refused.
    • (e) a waiter whose base conflicts with the holder is not admitted; one whose base applies cleanly still is.
    • (f) land-lock-check names the successor when next: is set and is byte-identical to today when it is not.
    • (m) a #MUTANT row in mise-tasks/land for each of the admission, speculation and settlement predicates, every one proven to turn its named case RED under mise run mutant.
  • Blockers (§8). blockedBy none — everything this builds on is landed on main. relatedTo CLOUD-472 (the gate that would have caught the divergence), CLOUD-418 (the mutation discipline), CLOUD-495 (the successor rewind, landed), CLOUD-420 (the CI-side enforcement whose redesign dropped the clause), CLOUD-399 (the lap and wait budgets), CLOUD-363 (a superseded run misread as red), CLOUD-240 (the rule scoping the cancel) and CLOUD-484 (runs that die without a verdict).

Done

main carries the conditioned admission, the conflict refusal, the diagnostic and the mutation rows, landed by fast-forward with CI green. A second matrix is spent only behind a green, non-conflicting holder, and every predicate #369 landed has been shown to fail.

CLOUD-520 `claim-check`'s has-pr reads a MERGED predecessor as a live competitor, so an issue released back to Todo cannot be re-pulled

Measured 2026-08-13, pulling CLOUD-479.

CLOUD-479 is Todo, unassigned, and its own body says so explicitly: a session landed one slice of it (PR #376), ran out of capacity, and released it back — "the honest column is Todo — the Ready block now passes mise run ready-lint, so it is pullable as-is by whoever takes it next."

claim-check refuses it:

CLOUD-479 has-pr (376)
::error:: claim-check: not pullable — someone is already on it.

PR #376 is state: closed, merged: true, merged 2026-08-12T20:56:06Z. Nobody is on it. The rule fired on a merged predecessor.

Why the rule cannot tell

has-pr reads the attachment URL and nothing else:

pr=$(jq -r '[.attachments // [] | .[] | .url | select(test("github\\.com/.+/pull/[0-9]+"))]
            | first // ""' <<<"$payload")

The URL carries no state. The rule's stated purpose is narrower than what it implements — its header says it "catches one that published before the column moved", which is a claim about an open PR. A merged one is the opposite signal: it is evidence that work finished, not that it is in flight.

This is issue-guard's solved problem, one gate over. That guard refuses a duplicate claim only when a different OPEN PR claims the key, and it goes through claimed-keys to decide it (CLOUD-378 is the correction that made both sides of that comparison use the same derivation). claim-check has no equivalent narrowing.

The tension this sits in, stated rather than hand-waved

claim-check is deliberately a pure function of stdin — no tracker credential, no network, so it cannot hang or rate-limit and its bats suite runs unconditionally. Asking GitHub whether #376 is open would break that, and it is the property the whole agents-fetch-gates-decide contract rests on.

So the fix is on the payload side, not the network side: the caller already fetches get_issue, and Linear's attachment objects carry more than a URL. If the attachment payload can distinguish a merged PR — or if the caller is asked to supply that state the way claimed-keys accepts --branch/--title/--log for a PR this checkout did not author — the rule can narrow to open PRs without the gate reaching the network. If it cannot, the honest alternative is to demote has-pr to a WARNING that names the PR and lets the caller decide, since a false refusal on a legitimately re-pullable issue is worse than a missed competitor that not-todo and assigned already cover.

Cost, so it is not overstated

One issue, once. The workaround is to take it over deliberately — which the gate's own message invites — but there is no bypass for it: BATTEN_CLAIM_CHECK_BYPASS (CLOUD-431) skips only the two refinement rules, and the three competitor rules have no hatch at all. So the operator's only route is to skip claim-check entirely, which is exactly the "a gate with false positives gets bypassed, and a bypassed gate enforces nothing" failure claim-guard's own header warns about.

Not this issue's subject. The receipt being branch-keyed and going stale after a merge is CLOUD-516. Whether a merged PR should complete its issue at all is CLOUD-468. This is only about has-pr refusing a pull.

The questions that blocked Ready, answered

Both are settled in the Ready block below: the payload does not expose PR state (measured), and the caller supplies it — the claimed-keys precedent — rather than the rule being demoted to a warning.

Filed while pulling CLOUD-479 rather than fixed in passing: narrowing this rule needs a decision about the payload contract that CLOUD-431's author is better placed to make, and widening claim-check's input a second time in one PR would land two contract changes under one commit's reasoning.


Refinement — Ready (narrow has-pr to a PR that is actually live, with the state supplied on the payload)

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

Open question 1 is answered, with evidence. Linear's get_issue attachment objects carry id, title, subtitle and url — and no state. Measured 2026-08-19 on this issue's sibling: CLOUD-369's payload returned two attachments, both PRs, both merged, and neither object said so. So the narrowing is not free from the payload as it stands, and a fix that waits for the tracker to expose state is waiting for something that is not coming.

Open question 2 is answered by the precedent one gate over. claimed-keys already accepts the facts it cannot fetch — --branch, --title, --log — so a checkout can decide about a PR it did not author without the gate reaching the network. The same shape applies here: the CALLER supplies the state, the gate stays a pure function of stdin, and agents-fetch-gates-decide is preserved rather than traded away. That is why this does not become a warning: a rule that can still refuse a live competitor is worth more than one that only advises, and demoting it would give up the protection in order to fix the false positive.

  • Source of truth (§1). mise-tasks/claim-check stays the one authority for pullability, and stays a pure function of stdin — no tracker credential, no network, no gh. The PR's state is an input the caller supplies, never something the gate fetches. Nothing here widens what the gate reads beyond the payload it is already given.
  • Computable predicate (§2). has-pr refuses only a PR the payload shows to be live. An attachment may carry a state (or merged) field the caller filled in; a PR whose state says merged or closed is a predecessor and does not refuse, and every other reading — open, malformed, or absent — refuses exactly as today. Absent-refuses is the load-bearing default: it keeps the rule's protection for every caller that supplies nothing, so this change can only ever turn a false refusal into a pull, never a real competitor into a silent pass.
  • Effect (§3). read. No new write, no new file, no network.
  • Output & exit (§5). Pointer-only: the issue id, the rule id, and the PR number — never a title and never a body. The refusal gains one clause naming how to supply the state, so a caller hitting the false positive is told the remedy rather than left to find the bypass. Exit codes unchanged: 0 pullable, 1 not pullable, 2 unreadable stdin.
  • Commit / bump (§6). fix → patch until 0.1.0.
  • Test obligation (§7). tests/claim-check.bats, each case asserting an exit code: (a) an attachment stating merged does not refuse, and the same payload without the state does; (b) an attachment stating closed-unmerged does not refuse; (c) an open PR still refuses — the rule's whole purpose, and the case proving this narrowing did not delete it; (d) a malformed state refuses rather than reading as merged, since a parse failure must not become a pass; (e) a non-PR attachment URL is still ignored. Plus a #MUTANT row proving the narrowing discriminates: with the state check neutered, case (a) goes RED.
  • Blockers (§8). blockedBy none. relatedTo CLOUD-516 (the receipt this refusal interacts with; a stronger instance is recorded there), CLOUD-431 (the refinement rules and the bypass that skips only those), CLOUD-378 (the both-sides-of-the-comparison correction this mirrors) and CLOUD-468 (whether a merged PR should complete its issue at all — deliberately not this).

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: 34 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: 3f4400b2-2ed0-4bcc-baac-f6ba89c648bf

📥 Commits

Reviewing files that changed from the base of the PR and between 1c86624 and 983ff63.

⛔ Files ignored due to path filters (1)
  • fuzz/Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (22)
  • bench/tokens/RESULTS.md
  • bench/tokens/workloads.toml
  • completions/batten.bash
  • completions/batten.fish
  • completions/batten.zsh
  • crates/batten/src/capture.rs
  • crates/batten/src/cli.rs
  • crates/batten/src/exec.rs
  • crates/batten/src/lib.rs
  • crates/batten/src/spec.rs
  • crates/batten/src/surface.rs
  • crates/batten/tests/cli.rs
  • crates/batten/tests/pointer_only.rs
  • man/batten-capture-list.1
  • man/batten-capture-prune.1
  • man/batten-capture-show.1
  • man/batten-capture.1
  • man/batten-exec.1
  • man/batten.1
  • mise-tasks/claim-check
  • tests/claim-check.bats
  • tests/token-bench.bats

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

… was out

The rebase onto 228c29d crossed four landed changes, each resolved rather than
absorbed:

- `-y --yes` became a GLOBAL flag when CLOUD-46 discharged CLOUD-42's G11, so
  this branch's per-command copy is dropped; `capture prune` reads the global.
  The destructive-row test is main's, plus the anti-vacuity counter this branch
  added — its predecessor asserted the set was EMPTY, so emptiness was the pass,
  and the successor asserts a property OF the set, where the same emptiness would
  be vacuous.
- `tests/pointer_only.rs`'s census requires every leaf verb to declare what it
  may emit. `capture show` is `Passthrough`: handing back the bytes the caller's
  own command wrote is the whole job, since the other way to see them is running
  that command again. `list` and `prune` are `PointerOnly`. The row states what
  the sweep does NOT exercise — its corpus holds no capture — rather than letting
  a 0-against-0 count read as proof.
- The `run` dispatcher was one line over the length lint once it gained two arms;
  the `exec` arm becomes `run_exec`.
- Completions and man pages are derived, so they are regenerated rather than
  hand-merged.

BREAKING CHANGE: the library API changes in three ways, all introduced by the
capture-navigation work earlier on this branch and declared here for the range.

`exec::run_with` and `exec::run_in_with` take an `exec::Mode` argument, so a
caller choosing between teeing the child's streams and capturing them must say
which it wants. There is no default parameter to fall back on deliberately:
`--capture-only` changes what every wrapped command's caller sees, and a
signature that let it be selected implicitly is the shape CLOUD-285's
transparency contract exists to prevent.

`Command::Exec` gains a `capture_only` field and the `Command` enum gains a
`Capture` variant, both of which a caller matching exhaustively must handle.

Refs: CLOUD-121
CLOUD-619 landed the helper and the census that enforces it while this branch
was out. Setting `XDG_DATA_HOME` by hand redirects nothing on Windows —
`etcetera` reads the roaming known folder there — so the child writes to the
real user profile and every assertion about the capture store is about someone
else's.

Refs: CLOUD-121
@wenzowski
wenzowski force-pushed the claude/cloud-369-grooming-fswo8x branch from 473a7f5 to 148b685 Compare August 19, 2026 07:54
`has-pr`'s stated purpose is narrower than what it implemented. Its own
header says it "catches one that published before the column moved" —
a claim about an OPEN pull request. A merged one is the opposite signal:
evidence that work finished, not that it is in flight.

So an issue released back to Todo could never be pulled again. Measured
on CLOUD-479 — Todo, unassigned, its body explicitly inviting the next
taker — refused on PR #376, which had merged the day before. The same
shape blocked CLOUD-369 today, whose two attached PRs are both merged.

The state cannot be looked up here, and that is the constraint rather
than an oversight: this gate is a pure function of stdin — no tracker
credential, no network, so it cannot hang or rate-limit and its suite
runs unconditionally — and the tracker's attachment objects carry `id`,
`title`, `subtitle` and `url`, with no state at all. So the caller
supplies it, the shape `claimed-keys` already uses for the facts it
cannot fetch.

ABSENT REFUSES, and that default is what makes this safe: a caller
supplying nothing gets exactly today's behaviour, so the narrowing can
only ever turn a false refusal into a pull, never a real competitor into
a silent pass. Open refuses, malformed refuses — a parse failure must
not become a pass — and only an explicit merged/closed reading stands
down. The refusal now names the remedy, since the alternative a caller
finds on its own is skipping the gate entirely.

Nine cases, and the load-bearing ones are the pairs: a merged PR is
pullable while the SAME payload without the state still refuses, and an
open PR still refuses — a narrowing that also stopped refusing live
competitors would pass every positive case and be worthless.

The `#MUTANT` row took two corrections, both worth naming. It first
reported `names-no-case`: `mutant` passes the case name to
`bats --filter`, which reads it as a REGEX, so a name containing `(a)`
matched nothing and proved nothing. Renamed to `clause a`. It then
SURVIVED: the row neutered the `merged` boolean while the `state` string
check still filtered merged PRs out, so the case passed on deliberately
broken code. It now neuters the state comparison, and 42 of 42 declared
mutations are caught.

Refs: CLOUD-520
@wenzowski
wenzowski marked this pull request as ready for review August 19, 2026 08:02
@wenzowski
wenzowski force-pushed the claude/cloud-369-grooming-fswo8x branch from 148b685 to 983ff63 Compare August 19, 2026 08:02
@sonarqubecloud

Copy link
Copy Markdown

@wenzowski

Copy link
Copy Markdown
Contributor Author

/fast-forward

@wenzowski
wenzowski merged commit 983ff63 into main Aug 19, 2026
10 checks passed
@wenzowski
wenzowski deleted the claude/cloud-369-grooming-fswo8x branch August 19, 2026 08:16
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