Development record
Spent reservation branches are deleted only by one GitHub App the ruleset names
Context
A reservation (adr-2610031352212697,
itd-2609091034175565) is a branch refs/heads/abcd-reserved/<record-id> on
origin. It ends with a tombstone pushed as a fast-forward, never a deletion,
and the reservations protection ruleset forbids deleting the branches or
forcing them, with no bypass actor: nobody silently removes someone else's
live claim. So every spent branch stays on the forge, 30 on 2026-10-16 and a
few more each day (iss-2610142200389875).
What was already fixed:
- The protection stays. A deletion must never remove a live claim, a standing hand-back (the drain work's dcn-2610161806433529, on its branch feat/drain-many-accounts until it lands, and dcn-2610161826443524), or anything abcd cannot classify.
- It must happen without anyone remembering to run it (the product thinker's ask).
- GitHub's own automation cannot be the bypass. A ruleset's bypass list
takes repository roles, teams, GitHub Apps, deploy keys and organisation
admins (REST
bypass_actors.actor_type:Integration,OrganizationAdmin,RepositoryRole,Team,DeployKey,User). Adding the GitHub Actions app was refused here with a 422, so a workflow's ownGITHUB_TOKENcannot delete under the ruleset. delete_branch_on_mergedoes not help. It deletes a pull request's head branch after a merge, and rulesets can stop even that; reservation branches are never merged.
Decision
We will delete spent reservation branches only through one GitHub App, the
reservation-sweep App, named as the reservations protection ruleset's only
bypass actor (actor_type: Integration, bypass_mode: always, so every
bypass is audited).
A scheduled workflow, .github/workflows/reservation-sweep.yml, runs once a
day. It mints a one-hour installation token for the App, scoped to this
repository's contents, and runs abcd reserve --sweep --apply. The sweep
judges each branch with the claim package's own lease verdict and
default-branch folders. It deletes a branch only when it is spent, and only
with a lease on the exact tip it judged, so a branch re-reserved meanwhile
survives.
A branch is spent when its tip has settled, its lease past the five-minute take-over margin included, and either:
- (a) its record sits only in terminal folders on origin's default branch; or
- (b) every commit on its first-parent line is a reservation or a plain release. That includes a reservation that lapsed without ever being released: once its lease lapses any session may take it over, so it protects nothing.
A hand-back on an open record keeps the branch. So does any commit the sweep cannot classify.
How long a settled tip waits depends on what it is (amended 2026-10-28, on the product thinker's ruling of that day, iss-2610281719275461; the first version made every branch wait seven days):
- A released branch, one whose tip is a plain release, waits for no age. The next daily run deletes it once the take-over margin has passed. A release is its holder's own act, so no holder is coming back to the branch. One writer may still have something to put on it: a drain whose hand-back was left pending because a person held the issue (dcn-2610161826443524) publishes that hand-back over the person's release, and the sweep may have deleted the release first. So a drain that finds the branch gone publishes the hand-back as the branch's first commit instead, with the same plain push every reservation act makes, which origin takes only while the branch is still absent: a session that reserves the issue meanwhile wins, and the drain keeps the hand-back pending for that session's release. A record finished on the default branch, or changed since the hand-back was judged, gets nothing published, as over a release, and so does a hand-back judged while the default branch could not be read, which names no record to fence on once the branch is gone.
- So does a branch whose tip is a hand-back on a record that is now terminal on origin's default branch: the task is finished, so the hand-back has nobody left to hand the record to. A hand-back on an open record still keeps its branch.
- Any other tip waits seven days past settling, a lapsed reservation never released among them. A lapse is no one's act: the holder's session may have stopped, as when a computer crashes, and the age gives that holder time to return and see the branch before it goes. Nothing found while making the amendment gave a reason to treat a lapse like a release.
The last two choices, the hand-back on a terminal record and the lapse keeping its age, were made by the technical facilitator on delegation (abcd-f5, 2026-10-28), reading the product thinker's question of why a branch is not removed once its task has finished. So was the drain publishing a pending hand-back over a branch the sweep deleted, chosen over keeping released branches whose issue a drain still holds a hand-back for, which no sweep can see from origin, and over dropping the hand-back, which would leave the issue open to every other drain.
The safety rules hold for both: the deletion is made only by the App, only with a lease on the exact tip judged, and a branch carrying a commit the sweep cannot classify is kept.
Alternatives Considered
- The repository-admin role as bypass, with an
abcd reserve --sweepan admin runs or that runs insidereserveandpeers. Rejected. It runs only when an admin happens to use abcd, and every other person's sweep is refused at the push. It also gives every admin's everyday credentials the power to delete or force any reservation, live ones included. - A deploy key as bypass, used by the scheduled workflow. Workable and
simpler to set up, but rejected. A
DeployKeybypass covers every deploy key the repository ever has, not one. A deploy key never expires. GitHub's own comparison prefers App installation tokens: tightly scoped, expiring after an hour. - A GitHub App as bypass, used by the scheduled workflow. Chosen. The
bypass names one App by id. Its token lives an hour and is revoked when the
job ends. The repository already mints App tokens this way
(
scripts/dependency-reauthor.sh), so no new action or dependency enters. - Dropping the deletion rule. Rejected: it is the protection the product thinker ruled for on 2026-10-12.
Consequences
- Setup by hand, once. Someone has to create the App, install it on the
repository with Contents read and write, give the
reservation-sweepenvironment (the first scheduled run creates it empty, with no rule) a deployment-branch rule of "Selected branches and tags" naming the default branch alone, never "Protected branches only", since any writer can push the ruleset-protected reservation branches, and only then setRESERVATION_SWEEP_APP_IDandRESERVATION_SWEEP_APP_KEYas that environment's secrets (never as repository secrets, which a workflow run from any branch can read), and apply the ruleset change mirrored in.abcd/work/rulesets/reservations-protection.json. Until then the workflow runs the dry run and deletes nothing. - Only the default branch reaches the key, and only the minting step holds
it. The job declares the environment, so a run from any other branch,
which a writer can start with
workflow_dispatchafter changing the code it runs, resolves neither secret. On the default branch the key lives in the step that mints the installation token and ends; the step that runs abcd's code receives the token alone, and a final step revokes it. - Only released code holds the token.
cmd/andinternal/merge without a required approval, so running the default branch's code would let a writer land code that reads the token on its next run. The job therefore runs the latest published release, never the default branch's code: it checks the binary's SHA-256 against the release'schecksums.txtand its build-provenance attestation againstrelease.ymlon main at the tagged commit, on a GitHub-hosted runner, before any token is minted. Code reaches the token only by passing a release cut. The cost: a fix to the sweep reaches the job only with the next release, and a release that predatesreserve --sweepmakes the run a stated no-op (technical facilitator, 2026-10-17). - A leaked App key is worse than before. Whoever holds it can delete or
force any reservation branch, which the ruleset forbids today. It can also
write any branch no other ruleset protects, as any write token can.
mainand the release tags stay protected, because their rulesets name no bypass. - People's own pushes are unchanged. The committed pre-push hook still
refuses a deletion under the prefix. A person's
--applyis refused by that hook and by origin. - The sweep reads hand-backs through claim's reader. Each commit's
message is read by
claim.ReadReservationMessage, the reader the drain reads a reservation branch with, so the sweep and the drain agree on what a hand-back is. The sweep stays stricter on top of it: a message that reader refuses, a hand-back kind outsideclaim.HandBackKinds, a trailer it does not know or a tree that is not empty keeps the branch.
Frontmatter
| id | adr-2610161940004937 |
|---|---|
| type | adr |
| status | proposed |
| date | 2026-10-16 |
.abcd/development/decisions/adrs/2610161940004937-spent-reservation-branches-are-deleted.md
open on GitHub ↗ · commit history ↗
References4
- adr-44abcd never mutates a remote uninvited, and identity derives from caller-local factsrelated
- adr-2610031352212697Claims are branches on the shared remote; nothing kept on one computer is the register; claim text is datarelated
- itd-118Merged work leaves no residue: abcd-managed repos delete a PR branch on merge, and abcd tidies the stale local branch, tracking ref, and worktreerelated
- itd-2609091034175565Before an agent starts on an item, it reserves it on the shared remote, and everyone who works on the project sees the reservationrelated
mentions5
- dcn-26101618064335292026-10-16 — A plain release keeps a hand-back that still stands (facilitator-on-delegation, drain-many-accounts review round): a release with no hand-back of its own reads the reservation branch's first-parent line below its tip, at most 256 commits, to the first tombstone, and when that tombstone marks a hand-back whose record origin's default branch holds unchanged by the reading handBackStands uses, the new tombstone carries its kind and blob forward and the result says so (Result.HandBackKept); the walk passes renewals and take-overs, and an unreadable commit, a plain tombstone, the root or the bound keep nothing. The carried tombstone names the person releasing as its holder, since the closed trailer form names no earlier holder and a new trailer would read as unreadable to every binary already released. A release asked to hand back over a tip a plain tombstone already released publishes the hand-back as that tombstone's child, fenced by Tip on the tombstone or the reservation it released; with HandBackBlob it publishes none once the record's blob is another. Reserving and releasing an issue is not a person's change to its record, and a failed build start's clean-up is not a person's decision, so neither erases the hand-back (grounds: dcn-2610161040365515, dcn-2610161412283450; spc-2610161044461448 step 3; the review of steps 1 to 18).
- dcn-26101618264435242026-10-16 — A yes whose hand-back a move cannot publish keeps it pending in the drain's state and publishes it over the issue's next release on origin (facilitator-on-delegation, drain-many-accounts review round): when the hold the yes would release has moved on (this person renewed or reserved the issue again by hand, another took it over, a person released it), when the drain took none because this person held the issue live from this copy, or when the release did not reach origin, the route says the hand-back is not yet published and why, the drain keeps it (pending_hand_backs: issue, kind, the blob the yes was judged over, why, when) and carries it into the next drain, and each later move whose view shows the issue's tip as a tombstone marking no hand-back publishes it as that tombstone's child, fenced on the tombstone and on the judged blob; a tip already marking a hand-back, a record finished or changed on origin's default branch, or one origin holds in no one file ends it with nothing published. The drain never releases a person's own reservation to publish it, so it waits for the person's release rather than taking the issue (grounds: dcn-2610161040365515, dcn-2610161806433529, dcn-2610161315385888; spc-2610161044461448 steps 2 and 3; the review of steps 1 to 18).
- iss-2610142200389875The reservations ruleset forbids deleting refs/heads/abcd-reserved/** with no bypass, so every spent reservation branch stays on the forge forever, counting against its per-repository branch limits and against the intent's 'needs occasional clean-up' (decision 10 of itd-2609091034175565); the rulesets README notes the itd-118 sweep needs the ruleset amended first.
- iss-261025221445887437-reserve.md:29-33 says the verb 'does one of two things' (reserve or release) plus 'a third form, the holder query'; the sweep form shipped on 2026-10-16 (internal/surface/cli/reserve_sweep.go:30; commands/reserve.md:293-329; adr-2610161940004937) and is absent from the chapter's prose and its Exits (:364-390), though its own appendix lists the three flags (:415-421).
- iss-2610281719275461A released reservation branch, one whose tip is a plain release commit, waits seven days before the daily reservation sweep deletes it, so spent branches pile up on the forge for a week after the work was handed in. The product thinker ruled on 2026-10-28 that such a branch is deleted at the next daily sweep run instead, keeping the sweep's other safety rules.