0042 — The mini's collectors read GitHub as the propflow-agent-smith App, on their own budget
- Status: Accepted; part 1 live. 2026-10-06:
~/.local/bin/gh-app-token+lib/gh_auth.pyandci-factory-mqare live (gera-propflow/local-bin#431, merged 04:58Z; first live lineok total=25 ... trigger=webhook:check_suite main auth=appat 04:59:49Z). The health and flow passes (local-bin#435) and the remaining scheduled readers (part 3) are in flight; the Build table below is the record of what has landed. The App was created and installed by Gera on 2026-10-06 and this move is what he asked for. - Date: 2026-10-06
- Deciders: Gera (create the App, move the collectors onto it). The shapes below were chosen by the worker that built them, per ADR-0004.
- Systems:
gh-app-token(new),ci-factory-mq(and its siblingsci-factory-mq-health,ci-factory-flow), then every otherpipelinein this catalog that reads GitHub on a timer.
The problem
Every GitHub call from this box ran as ONE user account (gera-propflow: 5,000 REST and 5,000 GraphQL points an hour), whether it came from a human's shell, a lane's gh pr view, or a collector on a timer. The collectors are the steady drain: ci-factory-mq does a floor pass every minute (3 REST + 1–4 GraphQL), the health pass reads failed-run logs every 15 minutes, the flow pass spends 500–750 REST every 6 hours, and the system-domain timers (puller 300 s, wt-merged-notify 120 s, tracker-refresh 300 s with hundreds of gh pr view a pass, the hourly cleaners) add their own. On 2026-10-06 03:31Z the account hit its limit and every agent on the box starved at once: PR creation, review watching, merges and the collectors all failed together, because they shared one budget and the budget had no owner. The same shape had already bitten on 2026-10-03 (the health pass refused at 00:17Z) and on 2026-08-26 (four accounts rate-limited by a fan-out). ci-factory-flow had grown a budget check for exactly this, which only proves the point: a reader that must check whether a human has left it any budget is on the wrong budget.
The decision
Scheduled readers are the GitHub App, not the user. The App
propflow-agent-smith(app id 5206201, installation 168375448 on PropFlow-Technologies, all repos) has read on actions, checks, contents, deployments, issues, pull_requests and administration, write on statuses, and its own budget: 15,000 REST and 10,000 GraphQL an hour, counted against the installation, not against anyone's login. A collector that reads on a timer runs itsghcalls withGH_TOKENset to an installation token.One minter, one contract.
~/.local/bin/gh-app-tokenprints an installation token: an RS256 JWT (iss = client_id, 9 minutes) signed byopenssl dgst -sha256 -signover the private key in~/.config/github-apps/propflow-agent-smith/(600 in a 700 directory), exchanged atPOST /app/installations/<id>/access_tokensfor a one-hour token, cached 600 in~/.cache/gh-app-token/, re-used until under 10 minutes of life remain, minted once under an fcntl lock when several callers arrive at once. Exit 1 and one line on stderr on any failure. Nothing prints or logs the key, the JWT or the token except the token to stdout. Python stdlib + openssl, so it has no dependencies to rot. The interimtoken.shGera's sessions were told to use execs it, so the contract (GH_TOKEN=$(...) gh ...) is one contract.Fallback to the user, said once, and the identity on every ok line. Consumers go through
lib/gh_auth.py(Identity.runin place ofsubprocess.run). If the token cannot be minted, or GitHub refuses it (HTTP 401/403: revoked, suspended, a permission the App lacks on that endpoint), the process falls back to the user's ownghauth, logsauth=fallback-user reason=...once, forgets the token (gh-app-token --forget) and retries that call. Every ok line ends withauth=apporauth=fallback-user. A page never goes dark because of the App; and when a collector is quietly back on the shared budget, the log says so and the catalog signal below goes stale.What stays on the user account, on purpose. Repos the App cannot see, first of all: the installation is on PropFlow-Technologies, so a read on
gera-propflow/local-binor any personal repo answers "Could not resolve to a Repository" (HTTP 404) with the App's token, and a reader that scans several repos runs a call as the App only when every owner it names is one the App can see (owner scoping:--repo o/n,repos/o/n,owner:"o"in a GraphQL batch; a mixed batch or an owner-less call stays on the user). Then writes other than statuses (PR create, merge, labels, comments: those are a person's or a lane's acts and carry their name), org billing (githubBilling), anything needing org admin, and user-scoped reads (gh api user,gh pr list --author @me, notifications): the App's token gets HTTP 403 "Resource not accessible by integration" on those, which is the right answer. A tool that mixes reads and such a call runs the one call with the plain environment and saysauth=userfor it.Cutover by load, in vertical slices. Part 1 the minter +
ci-factory-mq(the floor pass every minute; verified read-only against every endpoint it uses: mergeQueue, timeline and tag GraphQL, Actions runs, jobs and job logs throughgh, pulls, commit status). Part 2ci-factory-mq-healthandci-factory-flow(the flow scriptscripts/ci-flow/flow-baseline.tsreadsGH_TOKEN, so the same env is handed to it andread_budgetmeasures the budget it will spend). Part 3 the other scheduled readers, heaviest first. Each part is a PR offorigin/mainwith a control test that needs no real key (a throwaway key and a stubbed HTTP layer whose stub verifies the JWT signature).
Why this shape
- The budget follows the owner. An App's budget is the organisation's, sized for automation and separate from every person's login. A second user token would be the same 5,000 shape one more time and would starve the same way.
- A collector should not have to ask a human's budget for permission.
ci-factory-flow's budget gate stays (it now reads the App's budget), but it no longer skips passes because a lane spent the hour. - Least privilege by construction. The App cannot merge, cannot read a user, cannot see billing. The collectors never could do those by accident again, and the writes that must carry a name keep carrying it.
- Falling back is a signal, not a secret. The one-line
reason, theauth=field on every ok line and theauth=appsignal in SYSTEMS.md make "we are back on the shared budget" visible the moment it happens (ADR-0009), instead of being discovered the next time everything starves. - Measured, not assumed. The user account's spend is read from the
x-ratelimit-usedheader of one real cheap call (gh api rate_limitlied during the 2026-10-03 limit). Before any cutover: the account at its limit at 03:31Z, then 695 REST used in the first 21 minutes of the next window (04:34Z–04:55Z) with every collector still on it. The "Measured" column of the Build table carries the reading taken after each part landed.
Relation to other ADRs
- ADR-0003: the collectors keep integrating by signals; this only changes whose budget the reads spend.
- ADR-0009: the per-pass
<pass> ok auth=appsignals onci-factory-mq(floor, health, flow) go stale when that pass falls back to the shared budget while its own ok row stays green; all three rows read down until local-bin#435 lands and~/.local/binis fast-forwarded (the deployed floor line still carriesauth=at its end), which is the honest state of a cutover in progress.mint okongh-app-tokengoes stale when nothing has minted for three hours. - ADR-0004: this changes how every GitHub-reading pipeline relates to GitHub and to each other (one budget each instead of one budget for all), so it is written here at decision time, by the worker in the room.
- ADR-0041: the flow pass this ADR moves is the one that writes the history.
Build
| Part | Repo | What | State | Measured (user account, x-ratelimit-used) |
|---|---|---|---|---|
| 1 | gera-propflow/local-bin | gh-app-token, lib/gh_auth.py, ci-factory-mq on the App, two controls |
#431 merged 2026-10-06 04:58Z, live 04:59:49Z | 1,173 used at 05:10Z (37 min into the window), the App at 16 |
| 2 | gera-propflow/local-bin | ci-factory-mq-health + ci-factory-flow (and the flow script's GH_TOKEN) on the App; a refused token falls back too; a 403 rate limit never demotes |
#435 merged 05:29Z, live ( health ok auth=app 05:39Z) |
1,867 used by 05:28Z in the 04:34–05:34Z window (no limit hit) |
| 3a | propflow-docs | bin/refresh-tracker (hundreds of gh pr view a pass) on the App; owner scoping (the App sees one org); a 403 rate limit never demotes |
#315 merged 05:20Z, #316 merged 05:39Z, live ( github auth: app in tracker-refresh.out.log) |
— |
| 3b | gera-propflow/local-bin | lib/gh_auth.scope per call (owner + verb); puller (600 pages a tick), worker-cleaner, cerebrus-gate, merge-evidence-gate on the App; wt-merged-notify (no owner named) and lane-status (--author @me) stay on the user |
#437 merged 05:49Z, live | 430 used by 05:50Z in the 05:38–06:38Z window; the next full window is the honest after |
| 4 | PropFlow-Technologies/agent-smith | agent_smith/gh_auth.env_for per call; the nine hot gh helpers on the App, writes untouched |
#821 merged 06:06Z; live at the next runtime-refresh | — |
| — | agentflow | this ADR; SYSTEMS.md gh-app-token entry, per-pass ok auth=app signals on ci-factory-mq, auth=app on puller |
#295 merged; this PR | — |
What the measurement says (2026-10-06, every number the x-ratelimit-used header of one real GET /repos/PropFlow-Technologies/propflowai, never rate_limit, which reported used 0 for the App while its own real-call header said 458). The shared account hit 5,000 at 03:31Z with everything on it. Before any cutover it spent about 33 a minute (695 in the first 21 minutes of the 04:34Z window); after part 1 alone about 36 a minute (1,173 to 1,523 between 05:10Z and 05:18Z), because the ci-factory collectors were never the dominant spender: the calls= fields of their own ok lines in ~/.claude/services/ci-factory-mq/run.log sum to about 120 to 155 REST and 75 to 115 GraphQL an hour. The two heavy readers moved at 05:40Z (refresh-tracker) and 05:50Z (puller). In the 05:38Z window after that: the user account went from 490 at 05:54:38Z to 658 at 06:05:35Z, about 15 a minute including this session's own dozen calls, while the App's real-call header read 458 used by 06:05Z on its 15,000. The load moved: the App's counter rose by roughly what the user's stopped rising by. A full window with nothing else changing will give the clean figure; the per-pass auth=app rows and these two headers are how to read it any time.
Known limits at close: a demotion lasts the process (the next run mints again, which is wanted for a transient and costs one failed call for a permanent refusal); the App has no GraphQL rateLimit of its own visible to the user account, so the two budgets are read separately; writes that a collector makes (none today beyond R2) would need a human's token still.