Skip to content

ADR 0071: the forge is asked only what could have moved, a spent allowance pauses every process on the host, and one host takes a branch's requests in

Status: accepted · Date: 2026-10-28

Status: accepted by the user, 2026-10-28, in session.

Amended 2026-10-28 by the user, in session: a submit that does not wait fails at once on a spent allowance, exit 3, naming the time the pause ends, and makes no call; a waiting submit and a wait still wait the pause out within their own timeout (clause 134 as amended, blueprints 983cc03).

Amended 2026-10-28, checked by hand with the app’s installation 171576254, since no test reaches GitHub: GitHub answered 200 with a new ETag to a GET /repos/agentplot/sluice that carried, as If-None-Match, the ETag a token bought 61 seconds earlier had received. GitHub’s ETags depend on the token, so a kept answer saves a call only to the process whose token received it; the design holds as written.

Amended 2026-10-29 by the user, in session: “No intake ref on the remote yet means this host alone,” and then that a driver pushes the mark once when it starts, leased, and renews it rarely, never each pass, so that two drivers on one branch see each other. Where each host keeps its own records, a driver that finds no mark pushes it leased on its absence and renews it whenever a third of the lease has passed; a wait on a host where no driver lives, finding none, pushes it the same way, takes the requests in and lets it go when it ends; a step pushes none. A branch one host reaches with no driver, by waits and steps alone, keeps no mark between them (clause 137 as amended, blueprints f4e1bd7).

Deciders: the user, 2026-10-28, in session, on the audit of sluice’s GitHub App installation 171576254: (1) keep “something could have changed”, cheap and frequent, apart from discovering the pull requests people open on GitHub, which runs much less often, and always ask with ETags, the ETag kept per address on the host for every sluice process there; (2) read a request’s comments, reviews, checks and timeline again only once its head, its updated_at or its entry’s state moved, and a draft by the list alone; (3) when GitHub says the limit is spent, every process on the host waits until its reset, recorded in the host’s state, a submit or a wait waiting within its own timeout rather than failing, under rule core.29’s one retry policy; (4) one driver per branch across hosts; (5) one GraphQL query per repository per pass for the open requests with their reviews and head checks, in place of the reads per request, in this change; (6) the gate agent that restarts every seventeen seconds on mac-studio for want of docker is the host’s matter. Sources: agentplot/blueprints:design/sluice/requirements.md clauses 72, 73, 74, 128, 129, 133, 134 (as amended 2026-10-28), 135, 136 and 137 (added 2026-10-28), the C.5 pull request row, C.6, C.7 and C.8, S65 (blueprints 8bdf474); records 0021, 0044, 0056, 0061, 0062, 0068; rules core.11, core.14, core.25, core.29, core.31; crates/sluice/src/lib.rs (intake, wait_on, submit_pull, Command::Run), crates/sluice/src/profiles/github/pulls.rs (take_in, show, show_report, consider, missing, is_held, retried_by_hand, follow), rest.rs, app.rs, crates/sluice/src/retry.rs and crates/sluice-core/src/step.rs (keeps), as origin/main held them on 2026-10-28 (eddf83c2); the bolt the-driver-pushes-outcomes-and-waits-follow-it (its readers/<repository>.driver lock and driver_live); GitHub’s documentation read on 2026-10-28: “Best practices for using the REST API”, “Rate limits for the REST API”, “Rate limits and query limits for the GraphQL API”; GitHub community discussion 75228 and terraform-provider-github issue 3694 on ETags and installation tokens.

sluice’s app on GitHub calls the REST API between 9,000 and 12,000 times an hour against an allowance of 5,200, and is refused for about fifteen minutes of every hour. GitHub’s documentation warns that an integration that keeps calling while it is limited may be banned.

The calls come from intake. Every pass of sluice run, every three seconds, on every branch that takes pull requests, lists the open requests (take_in through rest.rs pulls); for each open request, drafts included, it reads sluice’s own check runs on the head (show, posted_all); for each request out of draft it reads the reviews and the other systems’ checks, check runs and statuses (missing); a request held or sent back reads its timeline (is_held, retried_by_hand); a request with a report reads its comments and its check runs again (show_report). Ten repositories on mac-studio share the installation. Every sluice wait, waiting submit and sluice step runs the same intake every three seconds for as long as it lasts, and a wait on deneb runs it on a branch that mac-studio’s sluice run already drives: the bolt that makes a wait follow its host’s driver sees only its own host. A 403 that says the limit is spent is read as GitHub’s answer, so a submit or a wait fails on it; a 429 is backed off by the retry policy’s own pauses, which know nothing of GitHub’s reset. Buying tokens is not the cost, and sluice overview calls nothing.

On the host that runs ten repositories, the records are each host’s own files: no branch names signers, so the lead of clause 72 does not exist there, and each host’s intake takes the same request into its own queue.

  1. Slow the passes: a longer pause for sluice run and the waits.
  2. Ask only what could have moved: discovery of the open requests at an interval the deployment sets, every read conditional with its last answer kept on the host, a request read again only once it or its entry moved, one query per branch where GitHub answers one; a spent allowance a pause every process on the host keeps; one host takes a branch’s requests in, across hosts.
  3. Webhooks: GitHub tells sluice when a request moves.

Option 2, as the user decided it.

Discovery apart from change. Which requests are open is the one thing sluice cannot learn on its own host, so it is asked on a clock: the deployment’s interval, a minute by default, set in the host’s [forge] table beside the app as discovery, never in a repository’s policy, since what it spends is the installation’s allowance and not a branch’s gate (74, 136). Everything else that moves is seen on the host first: the branch’s revision, its records’ version, a submit on the host that opened or moved a request. Any of those asks at once; nothing else asks between. The interval’s last asking is in the host’s state, so a process started again, or a supervisor’s restart loop, asks nothing that was asked within it.

Every read conditional, its answer kept on the host. GitHub does not count a 304 against the primary allowance when the request was authorised. Every GET sluice makes is sent with the ETag and the last-modified time of the last answer at that address, and a 304 is answered from the body kept with them. What is kept lives in the host’s state, one file per address, shared by every process of sluice on the host, and is used only when GitHub has just said it is unchanged: it decides nothing GitHub did not confirm (core.11, core.14).

GitHub’s ETags depend on the token the answer was made for (community discussion 75228, terraform-provider-github 3694; GitHub’s documentation is silent). Installation tokens are held in each process’s memory alone and never written down (the forge-identity capability), and that stands: this record writes no token anywhere. So the design holds under either answer. The ETag is always sent, whichever process stored it; where GitHub honours another token’s ETag, every process on the host gains, and where it does not, GitHub answers 200, which costs one call and replaces what is kept. The saving that does not depend on the answer comes from the long-lived process doing the asking: one host’s driver takes a branch’s requests in (137), with a token it keeps for its hour, and waits elsewhere ask only for their own request, rarely.

A request read again only once it moved. A request’s head, its updated_at and its entry’s id and state are kept with what was last read of it. Its reviews, the other systems’ checks, its ready marks and its comments are read again only once one of those moved; a request out of draft still waiting on its reviews or checks is read again at each discovery, conditionally, because another system’s check moves no request’s updated_at. A draft is read by the list alone. sluice’s own check and report are posted only when what they would say differs from what sluice last posted, kept on the host, and GitHub’s copy is read back only when the host holds none.

One query per branch. GitHub’s GraphQL API answers one query for every open request into a branch with its latest decisive reviews, its head’s checks and statuses, its ready marks and sluice’s own check runs. Asked at a discovery whose conditional list came back changed, or while a request out of draft waits on its conditions, it replaces the reads per request. GraphQL has its own allowance, and a query of fifty requests costs about three of its points (connections counted, divided by a hundred). The REST list stays the cheap question “did anything change?”, since GraphQL has no conditional answer.

A spent allowance is a pause for the whole host. A 403 or a 429 whose words or headers say a limit is spent, an answer leaving x-ratelimit-remaining at 0, and a GraphQL answer saying RATE_LIMITED are classed apart by the retry policy: no try is made until retry-after or x-ratelimit-reset, or for a minute and longer after each in a row where GitHub names neither, as its documentation asks. The pause is written in the host’s state, per installation and per allowance (x-ratelimit-resource, REST’s core apart from graphql), and every process reads it before it calls: none calls to find out. A command a person or an agent runs, a waiting submit, a wait, a status, waits the pause out within its own --timeout, saying once until when; a submit that does not wait fails at once, exit 3, naming the time the pause ends (the user, 2026-10-28); a round of sluice run or sluice step passes the forge’s part of its work by, says so once, and goes on landing what has passed (133, 134). This is rule core.29’s one policy with one more class, not a loop beside it.

One host takes a branch’s requests in. Where a branch’s records are on its remote, the instance that keeps the branch is already one across hosts (72): only it takes the requests in. Where each host keeps its own records, as on the ten repositories, there is nothing the hosts share but the remote, so the mark is a ref there, refs/sluice/intake/<branch>, naming the host, whether its holder is a driver or a wait, and the sluice that wrote it. It is renewed by a leased push whenever a third of the deployment’s lease has passed (five minutes by default, so about every hundred seconds), read in the fetch the branch’s round already makes, pushed where absent by a driver when it starts and by a wait on a host where no driver lives, never by a step, so a branch one host reaches by waits and steps alone keeps none between them (the user, 2026-10-29), taken from a wait by a driver at once, taken by anyone once it has stood unchanged for the lease by the taker’s own observation (73), and let go by a wait that ends. Since each host’s records are its own, a round on a host that does not hold the mark withdraws its own entries of the branch’s requests, none landing, naming the host that holds it; that host takes each in again, so no request is tested in two queues, as today’s intake on every host allows. A wait whose request another host takes in takes nothing in and runs no round: it follows its request by GitHub’s word, open, merged or closed, and sluice’s check on its head, read conditionally once in a quarter of the interval, and reports the outcome as it would an entry’s (137). On one host, the bolt the-driver-pushes-outcomes-and-waits-follow-it already makes a wait follow sluice run; this record adds nothing there.

Why not the others. Option 1 spends the same calls more slowly and slows every landing with them; the fan-out per request stays. Option 3 needs a service that GitHub can reach, which clause 135 and record 0068 refuse for any adopter, and a laptop behind a NAT has none.

  • Clause 134 is amended and clauses 136 and 137 are added to the statement, with the C.5 pull request row, the C.7 defaults, a C.8 row and scenario S65 (blueprints 8bdf474).
  • Rule core.29 gains the allowance’s pause; rule core.31 says a reader’s tick asks the forge nothing but what is due; rule core.32 binds the forge’s reads; rule core.33 binds the one host that takes requests in.
  • The host’s state gains forge/: the kept answers by address, the readings of each request, each branch’s last asking, the pauses per installation and allowance. None holds a token.
  • The host’s [forge] table gains discovery, a duration, default "60s", refused below ten seconds.
  • refs/sluice/intake/<branch> appears on the remote of a branch that takes requests and keeps its records in each host’s files once a driver starts or a wait on a host where no driver lives takes its requests in; a branch one host reaches by waits and steps alone keeps none between them.
  • The gate agent on mac-studio, which restarts every seventeen seconds because docker is missing, is the host’s to fix: it needs docker running before the agent starts, or the agent unloaded until it has it. sluice’s part is that a restart now asks GitHub nothing the host’s state shows was asked within the interval, and honours a pause another process recorded.
  • Whether GitHub answers 304 to an ETag another token received is checked by hand on a host with the app, since no test reaches GitHub; the answer is written here as an amendment, and the design does not change with it.