Artifact 4872f5bc336c19908566aecd34671293dab0bd57518465f927f85514a605c590:
- File docs/rfcs/0030-setup-by-approval.md — part of check-in [a3c7e434e5] at 2026-11-13 14:18:16 on branch agent-setup — agent.setup: one approval makes a new agent, grants its role and mints one narrow credential (user: dmestas, size: 43034) [annotate] [blame] [check-ins using] [more...]
- Feature Name:
setup-by-approval(one request and one approval give a repository its agent and credential; one command gives it a CI runner) - Start Date: 2026-11-12
- Status: Draft. The owner's decisions of 2026-11-12 are fixed; the rest is decided here and marked reversible (see Decisions).
- Depends on: RFC 0029 (onboarding, whose unbuilt items this RFC does not repeat); RFC 0021 and 0026 (agents, scopes, tokens); RFC 0027 and 0028 (asking your person, requests, plans); RFC 0019 (runners and job users)
- Amends: RFC 0027 Results (a secret may be bound to a pickup key, and a lapse revokes agent tokens as well as worker tokens); RFC 0029 Agent pushes and Your own runners; RFC 0019 isolation (the installer creates what that section asks an admin to create by hand)
- Tracks: tickets #421 (agent access) and #424 (runners). Waits on: quarry #416 and danmestas/zit#342 (first push of existing history). Upstream proposal: Appendix A.
Summary
RFC 0029 made each onboarding step something a person or an agent can
do alone. On 2026-11-12 a second repository, dmestas/suit, showed
that the steps are still too many and that the two that matter most
still end in a browser: a credential the owner mints and carries by
hand, and a runner built as root from another repository's unit. This
RFC replaces both with one act each. An agent the owner already
operates asks for "an agent NAME on repository R as ROLE with a
credential of KIND"; the owner approves once; the forge creates the
agent, grants the role, mints a narrow token and releases it once to
the holder of a pickup key, so the value never reaches a screen. A
repository admin clicks once for an install code; quarry runner
install on the runner's host does the rest. A runner that is stopped
hands its jobs back so they run again at once.
Motivation
What happened on 2026-11-12, on zeitforge.app, in order:
- A session needed an agent that could push to
dmestas/suit. No token may create an agent or mint its token, so the owner was sent to a settings page. - A repository agent took three places: Settings, Agents for the
name;
/me/agents/NAMEfor the token; a role elsewhere. The refusals on the way hid that the account did not exist yet. - The owner minted the token by hand. It came out with sync, api and web scopes on every repository, and he typed it into a Keychain prompt.
- The first push of existing local history into the empty repository was refused, and the work was redone in a fresh clone.
- A runner for suit took, as root: two system users, a sudoers file, directories, a unit copied from another repository, a worker token from a browser-only form, an env file. A scripted client that re-posted on the redirect switched three repository settings off.
- The session's first read of CI runs on a public repository was refused.
- A second client, the E desktop app, now needs a read-only token for private repositories: another agent, another token, more browser steps.
The owner: "this needs to be smoother take note and get a plan"; on credentials, "it needs something like auto setup and I just approve it"; on runners, "this should be easy for our consumers so if it needs design design".
Where RFC 0029 stands
Read against trunk on 2026-11-12.
| # | RFC 0029 promised | On trunk | Where |
|---|---|---|---|
| 1 | An empty repository adopts the first push's project code | Not built | quickStartSource in internal/web/web.go tells every reader to clone first and says a store made elsewhere cannot be pushed; ticket #416 |
| 2 | An agent pushes with a sync-scoped agent token got through its person | Built | agent.token.new is askable and marks its value secret:"once" (internal/api/agentops.go); ownCredential in internal/api/ask.go lets a repository-limited token ask for its own |
| 3 | SessionOnly on ops that refuse a token, printed in the docs, with a test |
Not built | No SessionOnly field exists; refuseToken (internal/api/register.go) is called inside each Run while the op still declares Auth: ops.Token |
| 4 | agent.token.new and me.sync.token named apart in their docs |
Built, differently | Both Docs now say minting adds; the one-sync-token rule itself is gone |
| 5 | A guide at /docs/start with whoami, walked by a test |
Partly | me.account answers GET /api/v1/me; the agent guide in internal/web/web.go has "A new repository, from nothing to CI"; there is no /docs/start, no walking test, and step 7 still says a site admin starts the runner |
| 6 | check: true on create and git-mirror |
Not built | No such field on either input |
| 7 | Worker tokens through the API, and a "no runner" notice | Partly | registerWorkerTokens in internal/api/workertokens.go (ci.worker.token.new, .list, .revoke, at /ci/worker-tokens); the settings page says "No runner has attached"; the CI page lists setup steps; nothing names the task type no runner serves |
| 8 | The Cloudflare skip rule, recorded in the deploy notes | Not recorded | Nothing under docs/ records the rule; it cannot be confirmed from the tree |
| 9 | Dotfiles (danmestas/zit#333) | Built on the forge side | The guide tells the reader to add dotfiles by name |
| 10 | A deploy loses no finished verdict | Built | Runner.finish retries with ResolveRetry (internal/runner/runner.go) |
| 11 | The guide shows binding a bucket step by step | Partly | One line of the guide names the call |
Four built, three partly, three not built, one not recorded.
What 2026-11-12 hit, and what covers it
| # | Friction | Covered by | State |
|---|---|---|---|
| 1 | No token may create an agent | RFC 0027 and 0028: askable, as a plan the asker composes | Partly. The asker chooses every scope, the page shows a sentence per step and no scope, and the result is three separate acts. Agent setup |
| 2a | Three places to make a repository agent | Landed 2026-11-12: Settings, Agents takes a role with the name; a repository's admins grant its own agents (refuseOutOfScope) |
Covered for name and role. The token is still a separate page. Agent setup |
| 2b | Refusals that hid a missing account | Landed 2026-11-12: agentToPutToWork says "not an agent"; the sync front tells an unverified card one sentence and uses LetterlessSecrets only after a card verifies; preflights in internal/api/ask.go refuses at ask time |
Covered |
| 3 | A hand-minted token, too wide, typed into a prompt | Nothing | Uncovered. Agent setup and Pickup |
| 4 | First push of existing history refused | RFC 0029 item 1, not built | First push |
| 5a | Worker token only from a browser form, shown by a flash | Landed 2026-11-12: the three worker-token ops; an agent asks and the value is released once; sealed and lapsing (internal/api/signoffsecret.go) |
Covered for an agent with a person. Uncovered for a person at their own host. Install codes |
| 5b | Users, sudoers, directories, unit, env file by hand | Nothing | Uncovered. A runner by one command |
| 5c | A re-posted form switched settings off | Landed 2026-11-12: settings forms change only what the request names | Covered |
| 6 | Reading CI on a public repository refused | Nothing | Uncovered. Reading CI |
| 7 | A read-only client for private repositories | Nothing | Uncovered. Agent setup, kind read |
Three covered by what landed, one by RFC 0029 as written and unbuilt, six uncovered or partly covered and designed below.
One finding beside the table. A lapse revokes only worker tokens:
lapseRefOf in internal/api/signoffsecret.go returns nothing for any
other op, so an agent token minted on approval and never collected is
cleared from the request but stays live. Build step 1 closes that.
Design
Agent setup by one approval
A plan (RFC 0028) can already create an agent, grant it and mint its token. It is the wrong vehicle for setup: the asker picks every scope, the page cannot show what the credential reaches, and a failure at the second step leaves an agent with no role. Setup is therefore one operation with fixed defaults, which the existing request machinery carries unchanged.
The operation. agent.setup, POST /api/v1/agents/setup. Like its
siblings it refuses a token (refuseToken), so an agent never runs it;
an agent asks for it with Quarry-Ask.
{"name": "suit-push", "kind": "push", "repos": "dmestas/suit", "role": "developer"}
{"name": "e-desktop", "kind": "read", "repos": "dmestas/suit,dmestas/context"}
| Field | Meaning |
|---|---|
name |
The agent's account name. |
kind |
push or read. |
repos |
Repository addresses, comma-separated. push takes exactly one. read takes one to twenty. |
role |
For push: developer (the default), publisher or maintainer. Refused for read, whose role is always reader. admin is refused for both. |
days |
A fixed life for the token, up to 366. Zero, the default, is no date: the token lapses after 90 days unused (RFC 0026). |
belongs_to |
repository or person. Empty takes the rule below. |
What it makes. The credential is fixed by the kind and is not an input.
| Kind | Token scopes | Token repositories | Token ceiling |
|---|---|---|---|
push |
sync only |
the one repository | the role's letters |
read |
api only |
the named repositories | gjorzh28: the letters every visitor holds on a public repository, and seeing CI |
A push token cannot call the API or open a browser session. A read
token cannot sync, post, or change anything, whatever role the agent
is later given.
Whose agent it is.
push, and the approver holdsaon the repository: the agent is the repository's own (repo:R, RFC 0026), answerable to the approver. It holds what it is granted there and nothing else, it survives the approver losing their letters, and the repository's other admins can manage it.push, and the approver does not holdathere: the agent is the approver's own, scoped torepo:R, with inherit off. Its grant is clipped to the approver's letters; if the clip would take anything from the role asked for, the request is refused and says which letters are missing.read: always the approver's own, with inherit off, scoped torepo:Rfor one repository and to the site for several. It is grantedreaderon each repository named. A client reading several of a person's repositories belongs to the person, not to any one of them.belongs_tooverrides the first rule towardsperson. It cannot make a repository agent where the approver holds noa.
Setup makes a new agent only. A name that is already an account, in any case, or that was one, is refused, and nothing the existing account holds is touched.
Who may ask. An agent whose personFor (internal/api/ask.go)
names a person: a personal agent, or a forge agent with its answerable
admin. A repository's or a team's own agent has no person there and
cannot ask, which is what keeps an agent made by setup from asking for
more agents. Nobody holding only a code or a link can file one.
mayAsk gains one rule beside ownCredential: a token limited to
repositories may ask for agent.setup when every repository named is
one it reaches. The person must be able to read every repository named
and, for push, hold the role's letters or a there.
agent.setup joins preflights. Refused when asked, in words: a name
that is a person or somebody else's agent, a bad kind or role, a wrong
count of repositories. Refused as the plain call is refused, saying
nothing more: a repository the asker cannot read, or one the person
does not hold. Account names are public on the forge, so "that name is
taken" tells an asker nothing new.
What the owner sees. The request's row and email read "Set up agent
suit-push to push to dmestas/suit". Its page, /me/requests/{id},
shows every line below before the buttons, each with a data hook:
| Line | Example |
|---|---|
| Asked by | zeitgeist, forge agent. "suit needs a pushing agent for its CI release" |
| Agent | suit-push, new. Belongs to the repository dmestas/suit; its admins manage it. |
| Role | developer on dmestas/suit: checks in, on any branch that is not protected. |
| Credential scope | sync: push and pull. No API, no browser session. |
| Credential reaches | dmestas/suit only. |
| Credential ceiling | developer. |
| Credential expires | No fixed date. Lapses after 90 days unused. |
| Collected by | The holder of pickup code 3f9a 81c2 07de. Or: the asking agent, zeitgeist. |
| If not collected | Revoked 24 hours after you approve. |
| Check-ins | Pushes are recorded as pushed by suit-push. Each check-in keeps the author it names. |
Approve and Deny work as for any request: from the page, from the Waiting row, or through the email's button to the page (RFC 0028).
Running it. Approval runs the operation as the approver. The store
gains one method, SetupAgent, that inserts the account, writes each
grant and mints the token in a single write transaction. The agent,
its role and its token exist together or not at all. What follows the
commit (the notify subscriber, topic membership, the account-change
event) is best-effort, as it is for agent.new and agent.grant
today.
Partial failure.
| What fails | Outcome |
|---|---|
| The name was taken, or a repository changed, between asking and approving | The transaction does not start. The request reads failed with the reason. Nothing was made. |
| A grant is refused inside the transaction | The transaction rolls back. failed, with the grant's own refusal. Nothing was made. |
| The forge has no secret key to hold the value | Checked before the transaction (SecretBox.Ready). failed. Nothing was made. |
| Sealing fails after the commit | The token is revoked. An agent this request created is deleted; an agent that existed keeps its earlier grants and loses the new ones. failed. |
| A best-effort step after the commit fails | The request is approved. The step is logged and retried by its own upkeep. |
There is no state in which an agent exists with a grant and no way to get its credential, or with a credential and no grant.
What is logged.
- The forge journal:
agent.setupby "ASKER (signed off by OWNER)", as every approved request is. - Each repository's settings log, actor the approver:
agent "suit-push" set up as developer; token "setup 4be19c02" (ID), sync, this repository (asked by zeitgeist, request ID). Never the value. - The account-change event for each grant, as
agent.grantraises. - The token's name is
setupand the request's short id, soagent.token.listshows where it came from. - Collection and lapse, each one more settings-log row:
token ID collectedortoken ID revoked: lapsed uncollected.
Collecting. The operation's answer has a public half and a secret
half (secret:"once", as agent.token.new):
{"agent": "suit-push", "operator": "repo:dmestas/suit", "created": true,
"grants": [{"repo": "dmestas/suit", "role": "developer"}],
"token": {"id": "...", "name": "setup 4be19c02", "scopes": ["sync"],
"repos": ["dmestas/suit"], "ceiling": "...", "expires": ""},
"remotes": [{"repo": "dmestas/suit", "url": "https://zeitforge.app/r/dmestas/suit",
"project_code": "..."}]}
The secret half is {"value": "..."}. A request asked without a pickup
key releases it as today: GET /api/v1/signoffs/{id} by the asking
agent, once. A request asked with a pickup key releases it only through
pickup. Either way it is sealed while it waits
and lapses 24 hours after approval (SignOffSecretLife), and a lapse
now revokes the token.
The author on the timeline. The sync front records the pusher from
the verified login and never from the check-in's U card
(internal/xfer/xfer.go). So the choice is already the owner's, per
checkout: commit as yourself and push as the agent, and the timeline
shows your name with the agent as pusher; commit as the agent, and it
shows the agent. Setup takes no author field and changes no default
user anywhere.
Credential pickup
A value read with signoff.get lands in a JSON answer on the asking
session's screen or in its transcript. Pickup removes that step: the
program that will use the credential fetches it.
The key. The requester makes 32 random bytes, the pickup key, and keeps them in a file of mode 0600. The pickup code is the lowercase hex SHA-256 of those bytes. The code is not a secret: it goes into the request, onto the owner's page and into logs.
Asking with a key. Any Quarry-Ask call may add
Quarry-Pickup: <code>; POST /api/v1/requests takes "pickup":
"<code>". The request row stores the code. Asking the same pending
call again with another code replaces the code. One code binds one
request.
Collecting. POST /api/v1/pickups/collect with
Authorization: Pickup <key, base64url> and no other credential.
| Answer | When |
|---|---|
202 {"state": "pending"} |
The request waits for its person. |
200 {"request": ID, "op": NAME, "result": {...}, "secret": {...}} |
Approved. Returned once; the stored secret is cleared in the same statement that reads it. |
410 {"state": S, "error": WORDS} |
denied, failed, expired, lapsed or collected. |
404 |
No request holds this key's code. |
POST /r/{repo}/pickup is the same handler for a client that knows
only a sync URL. It answers 409, consuming nothing, when the
credential does not reach that repository.
Rules.
- A request bound to a code never releases its secret through
signoff.get, to the asking agent or anyone.signoff.getstill gives state and the public half. - The route is rate-limited per address (ten a minute). A key is 256 bits, so guessing is not the risk; noise is.
- The route carries an
Authorizationheader, so it passes the Cloudflare rule of RFC 0029 decision 4. - Storage: two columns on
signoff,pickup(the code, unique when set) andcollected(when).
A client that is not an agent. E cannot ask: it is neither the
owner's agent nor the forge agent. E makes a key and shows its code.
The owner's agent asks for agent.setup of kind read with that code.
The page shows the code beside "Collected by", so the owner can compare
it with what E displays. E polls the collect route and stores the token
in the OS keychain itself. The value passes through no agent, no
terminal and no person.
Into zit. zit pickup new makes the key and prints the code.
zit clone URL DIR --pickup CODE and zit login --pickup CODE collect
from URL/pickup, derive the sync secret from the project code, the
agent's name and the value, save it as an authenticated push would, and
discard the value and the key. Nothing secret is printed or passed on a
command line. The proposal for zit is Appendix A.
Until it ships, a pushing agent's requester asks without a key and
reads the value once into a 0600 file, as the guide says today.
A CI runner by one command
sudo quarry-runner install https://zeitforge.app/r/dmestas/suit --code qri_4mx7k2p9wq3hd5tnb8ya
The same subcommands exist under quarry runner. A first argument that
is a URL or a flag runs the runner as today, so existing units keep
working.
Install codes
The person installing a runner is often at their own host, with no agent and no token. They cannot be allowed to file a request at an owner, and they should not be sent to copy a long-lived credential out of a browser. An install code is what the repository's admin hands them.
- Made by an admin, with one click. Settings, Runners gets one button, "Add a runner". The click answers in place with the full command above. No redirect carries the code.
- Also an op.
ci.runner.code.new,POST /api/v1/r/{repo}/ci/runner-codes, capabilitya. It refuses an agent asci.worker.token.newdoes, and an agent may ask for it. Itscodeissecret:"once". - Short-lived, single use, narrow. A code lives 15 minutes, is spent by its first redemption, and can do one thing: mint one worker token for that repository's worker group. At most five live codes a repository. The forge stores the code's hash, who made it, and when.
- Redeeming.
POST /api/v1/r/{repo}/ci/runner-codes/redeemwith{"code", "host", "label"}and no account. The forge checks that the maker still holdsaand the repository is the same project, mints the token through the same path asci.worker.token.newas the maker, and returns the value in that one response. - One refusal. A wrong, spent or expired code, and a repository the
caller cannot see, all answer
404 no such install code, or it has expired or been used. Five failures against one repository in 15 minutes close redemption there for the rest of the window. - Logged. The settings log:
install code madeby the admin; thenminted "HOST" (ID) by install code, made by ADMIN, redeemed from ADDRESS.
This does not reopen the decision that nobody holding a one-time code may put an agent-setup request in front of the owner. That decision is about who may start a request. An install code is not a request and asks nobody anything: an admin starts it, already signed in, and the code only carries that admin's finished decision to one host. A stranger cannot obtain one, and a code cannot create an account, grant a role or reach anything but one repository's CI queue.
A runner whose token an agent already obtained (by asking for
ci.worker.token.new) installs with QUARRY_RUNNER_TOKEN set and no
code. The code may also come from QUARRY_INSTALL_CODE or a no-echo
prompt.
What install creates
On Linux with systemd, as root. S is the repository's worker group
as the redeem answer names it, cut to 20 characters with four hex
digits of its hash appended when cut, so every name fits a Unix user
name.
| What | Name | Owner and mode |
|---|---|---|
| The program | /usr/local/lib/quarry-runner/quarry-runner, a copy of the running binary |
root, 0755 |
| Service user and group | qr-S, system, shell nologin, home /var/lib/quarry-runner/S |
|
| Job users | qj-S-1, qj-S-2 (--jobs N changes the count, which is also --max-tasks), system, no home, each with a group of its own; qr-S is added to each group |
|
| Home | /var/lib/quarry-runner/S |
qr-S, 0711 |
| Work directory | /var/lib/quarry-runner/S/work |
qr-S, 0750 |
| Sudo rule | /etc/sudoers.d/quarry-runner-S: qr-S ALL=(qj-S-1,qj-S-2) NOPASSWD: ALL, checked with visudo -cf before it is moved into place |
root, 0440 |
| Env file | /etc/quarry-runner/S.env: QUARRY_RUNNER_TOKEN=... |
root, 0600 |
| Manifest | /etc/quarry-runner/S.json: the URL, every user, group and path this install created, the token's id, the version |
root, 0644 |
| Unit | /etc/systemd/system/quarry-runner-S.service |
root, 0644 |
[Unit]
Description=quarry runner for https://zeitforge.app/r/dmestas/suit
After=network-online.target
Wants=network-online.target
[Service]
User=qr-S
Group=qr-S
WorkingDirectory=/var/lib/quarry-runner/S
EnvironmentFile=/etc/quarry-runner/S.env
Environment=HOME=/var/lib/quarry-runner/S
ExecStart=/usr/local/lib/quarry-runner/quarry-runner --work /var/lib/quarry-runner/S/work --max-tasks 2 --job-users qj-S-1,qj-S-2 https://zeitforge.app/r/dmestas/suit
Restart=always
RestartSec=5
KillMode=mixed
TimeoutStopSec=90
[Install]
WantedBy=multi-user.target
--checkout zit, --zit PATH, --branch NAME and --tasks LIST pass
through to ExecStart.
Order: check the host (root, systemd, useradd, sudo, visudo, the
forge reachable); create users, directories, the sudo rule, the unit;
redeem the code; write the env file; systemctl enable --now; run
doctor. Every step is idempotent, so a second run repairs a first. A
failure before the redeem removes what this run created and spends no
code. A failure after it leaves the token in the env file and says
which doctor check to read. Nothing runs as root after install: the
unit runs as qr-S and jobs as the job users.
Doctor
quarry-runner doctor S (or the URL; with one runner installed, no
argument). One line a check, each ending ok, warn or FAIL, and
--json for the same as data.
| # | Check | Failing is |
|---|---|---|
| 1 | The manifest reads | FAIL |
| 2 | The program exists; its version against the forge's /runner/SHA256SUMS |
FAIL if missing, warn if older |
| 3 | Every user and group in the manifest exists; qr-S is in each job group |
FAIL |
| 4 | As qr-S, sudo -n -u JOBUSER runs job-reclaim for each job user |
FAIL |
| 5 | Home, work directory, env file and sudo rule have the owners and modes above | FAIL |
| 6 | The env file holds a token | FAIL |
| 7 | The unit is enabled and active | FAIL |
| 8 | The forge answers GET /api/v1 |
FAIL |
| 9 | The bridge accepts the token (/v1/workers/connect) |
FAIL, with "revoked or wrong repository" |
| 10 | Trunk has a workflow in ci/ or a ci/runner.json |
warn: "nothing to run yet" |
| 11 | With --checkout zit, the zit binary runs |
FAIL |
| 12 | The work directory's disk has a gigabyte free | warn |
Exit 0 when every line is ok, 1 when any is FAIL, 3 when the worst is a warn, 2 for a usage error.
Uninstall
quarry-runner uninstall S stops and disables the unit, lets running
jobs be handed back (below), retires the token, then removes the unit,
the sudo rule, the env file, the job users and their groups, the
service user, the work directory and the manifest. It removes only
what the manifest lists. --keep-work leaves the work directory.
Retiring needs one small route, because a worker token cannot call
ci.worker.token.revoke: POST /api/v1/r/{repo}/ci/worker-tokens/retire,
authenticated by the worker token, revokes that token and no other, and
logs revoked by its holder (uninstall on HOST).
Without root
quarry-runner install --user URL --code CODE needs no root and makes
no users. It writes a systemd user unit
(~/.config/systemd/user/quarry-runner-S.service), an env file
(~/.config/quarry-runner/S.env, 0600), a manifest beside it, and
works in ~/.cache/quarry-runner/S. It prints the loginctl
enable-linger line for a runner that should outlive the login, and
does not run it. On macOS it writes a launchd agent
(~/Library/LaunchAgents/app.quarry.runner.S.plist) instead; macOS has
only this mode.
Such a runner has no job users, so it is never delivered a secret and refuses a job that asks for one, as today (RFC 0019). Install and doctor both say so in one line: "this runner takes no job that uses secrets; install as root for that".
A runner stopped in the middle of a job
Today a stopping runner cancels its context: the job dies, finish
returns at once, and the task stays claimed until its heartbeat lapses.
Since 2026-11-12 the next start hands workspaces back
(handBackWorkspaces); the engine still waits.
- On SIGTERM the runner stops polling. For each running task it
ends the job and whatever the job user left running, hands the
workspace back, writes
stopped: the runner was restarted; this job will run againto the task's log, and resolves the task with the engine'spauseaction and a delay of one second. The bridge then redelivers the task to any runner of the group. These calls use a context of their own with a 30-second limit, not the cancelled one. The unit'sKillMode=mixedandTimeoutStopSec=90give them room. - After a kill or a power loss nothing was reported. The runner
writes a marker,
work/running/TASKID, when it claims a task and removes it when the task resolves. At start, before polling, it resolves every marker it finds with the samepauseand removes it. A task the engine already gave elsewhere refuses the call, which is ignored. - A hand-back is not a failure. The run page shows the attempt as "did not finish" and the next attempt beside it, as it already does for a lost attempt. If the engine counts a redelivery against a workflow's retry limit, that is corrected in dagnats, the owner's own repository, not worked around in the runner.
Jobs are handed back, not drained. A deploy restarts runners, and a drain would hold a deploy for as long as the longest job.
Reading CI on a public repository
Today a visitor with no session holds the nobody category, which
never holds 8; any signed-in account holds it on a public repository
through anonymous (Visibilities in internal/oltp/roles.go).
Without 8 the API also blanks every CI fact carried beside other
things: a check-in's verdict, a landing's checks, a timeline row's
badge (hideCI in internal/api/register.go).
Verdicts open, logs stay shut. A check's name and state (pass,
fail, running) become readable by whoever can read the check-in:
hideCI keeps them and blanks only the run id and its link. A verdict
is a word the forge wrote, not output a program wrote, and a reader
judging a public project needs it. Steps and logs keep needing 8,
and a visitor with no session does not hold it by default.
- A log is the one surface on a repository that prints whatever a program wrote. Masking replaces the exact values of delivered secrets. It does not catch a secret that was encoded, split or derived, nor host names, paths and private dependency addresses.
- There are no forks here, so the usual reason for public logs (a stranger's contribution ran and they need to read why it failed) does not arise: whoever can push can sign in.
- An anonymous log reader is mostly a crawler, and log streams are the costliest read the forge serves.
An admin may open it. Settings, visibility gets one box under the
public presets, "Anyone may see CI runs and logs", which adds 8 to
nobody for that repository. The comment in roles.go that nobody
never holds 8 becomes "never by default".
The refusal says what to do. With no credential, on a repository
the caller can otherwise read: 401, "CI runs and logs on REPO are
shown to signed-in accounts. Send any account's API token, or sign
in." With a token whose ceiling lacks 8: 403, "this token's ceiling
does not include seeing CI on REPO". Both setup kinds above carry 8,
so neither client made by this RFC meets either.
Who am I, and what a token read
Who am I. No new route. me.account (GET /api/v1/me) already
names the account and what it reaches. It gains two things:
{"name": "e-desktop", "operator": "dmestas", "answerable": "",
"token": {"id": "...", "name": "setup 9c01d7aa", "scopes": ["api"],
"repos": ["dmestas/suit", "dmestas/context"], "ceiling": "gjorzh28",
"expires": "", "last_used": "2026-11-12T18:04:00Z"}}
operator is empty for a person. token is present when the call was
made on a token and absent on a session. A client can then show its
user exactly what it holds and warn before an expiry, and an agent
handed a credential can check it without a probing call.
Read audit: counted, not listed. A row for every read would put a write on the forge's hottest path and would keep a record of what a person browsed, which nobody has asked for. Counts answer the question an owner has about a client: which private repositories did this token touch, and when.
- A table
token_use (token, repo, day, reads, writes), one row a token, repository and UTC day. - Counted in memory and flushed once a minute, the way
last_usedis already throttled (lastUsedEvery,internal/oltp/token.go). Losing the last minute on a crash is accepted. - Kept 90 days.
- Read by the agent's manager:
agent.token.listgainsusefor each token (the last 30 days by repository), and the agent's page shows it under each token. - A token never reads another token's counts.
me.accountshows a token only its ownlast_used.
The first push of existing history
This stays with quarry #416 and danmestas/zit#342, and RFC 0029's
decision 1 stands. This design assumes only what is true today: the
repository exists, its project code is fixed, and the first push comes
from a clone of it. The setup answer and the pickup both carry
project_code, so a client can tell before it pushes that a store is
another project and say so in its own words. When #416 lands, nothing
here changes: an adopting push derives its secret from the adopted
code, and zit login --pickup saves a login for whichever code the
forge then reports.
Build order
Each step lands alone. "Sign-off" marks a step that changes a page somebody sees: the owner approves the real output on real data before it lands.
- A lapse revokes agent tokens.
lapseRefnames an account's token as well as a worker token. Test: an approvedagent.token.newleft uncollected past the limit has its token revoked and one settings-log row. - Pickup. The two columns,
Quarry-Pickup, thepickupfield, both collect routes. Test: the key collects once and then gets410;signoff.geton the same request never returns the secret. agent.setup. The op,SetupAgentin one transaction, themayAskrule, the preflight. Test: an approved request yields an agent, a grant and a token whose scopes aresyncand whose repositories are the one named; a forced grant failure leaves no account row.- The setup request's page. Sign-off. Test: the page carries a hook each for scope, repositories, ceiling, expiry and collector, with the values of the stored request.
me.accountnames the token. Test: a call on a token answers its own id, scopes, repositories and ceiling; a session answers notoken.- Token use counts. The table, the flush,
agent.token.list, the agent's page. Sign-off for the page. Test: two reads and a push on one token show asreads 2, writes 1for that repository and day. - CI verdicts, refusals and the visibility box. Sign-off for the
box and for the badge a signed-out visitor now sees. Test: with no
credential
checkin.getcarries each check's name and state and no run id, a run's log gets the401sentence, and with the box ticked the same call answers the log. - Hand-back on stop and at start. Test: SIGTERM during a job
resolves its task with
pauseand a second runner takes it within ten seconds; a marker left by a killed runner is resolved at start. - Install codes. The op, redeem, retire, and the "Add a runner"
button. Sign-off for the button. Test: a code redeems once for a
token scoped to its repository's group; a second redemption and a
stale code get the one
404; six bad codes close the window. install,doctor,uninstallas root. Test: in a Linux container with systemd, install against a test forge makes doctor exit 0 and a job with a secret pass; uninstall leaves no user, file or unit from the manifest, and the token reads revoked.--userand macOS. Test: install without root, doctor exits 0 with the no-secrets line, and a job that asks for a secret is refused.- The guide. RFC 0029's step 6, written around setup, pickup and install, with its walking test. Sign-off. Test: the test follows the guide's calls against a test forge from an empty forge to a green run.
- zit. File Appendix A at
danmestas/zit; when it ships, the guide's push recipe uses it.
Test:
zit clone --pickupagainst a test forge leaves a saved login and prints no secret.
RFC 0029's own unbuilt steps (first push, SessionOnly, check:
true) keep their place in its build order.
Decisions
Settled by the owner on 2026-11-12:
- The owner approves; the forge does the setup. One request, one approval, from a phone if need be.
- Only agents the owner already operates, and the forge agent, may put an agent-setup request in front of him. Not anyone holding a one-time code.
- An agent never mints its own credentials directly; it asks.
- A credential minted on approval is never shown in a browser or passed through a person's hands. It is released once to the requester, sealed while it waits, and lapses if uncollected.
- A pushing agent's default credential is sync scope only, limited to the one repository. A read-only client's is api scope only, named repositories, a read-only ceiling.
- The check-in author is the owner's choice per repository; the flow does not force the agent's name onto the timeline.
- Setting up a CI runner is easy for consumers: people other than the owner, on their own forge or their own host.
Decided here, reversible:
- Setup is one operation, not a plan. Reason: fixed defaults, one transaction, and a page that can state what the credential reaches.
- A pushing agent belongs to the repository when the approver administers it, and to the approver otherwise; a reading client always belongs to the approver. Reason: a repository's automation should outlive one person's role there, and a client that reads several repositories is nobody's but its user's.
- The read-only ceiling is
gjorzh28. Reason: it is exactly what a visitor reads on a public repository, plus CI. admincannot be asked for through setup. Reason: an agent that administers a repository can mint runner codes and change who reaches it; that stays a separate, visible grant.- A pickup key is a bearer secret whose hash is the public code, not a key pair. Reason: one hash, no new cryptography, and the forge keeps sealing at rest as it does now.
- A request bound to a pickup code never releases its secret through
signoff.get. Reason: one path out, so the page's "Collected by" line is true. - A lapse revokes the token and leaves an agent it created in place. Reason: the agent holds no credential and is visible in settings; deleting accounts on a timer is the larger surprise.
- An install code lives 15 minutes, redeems once, and may appear on a command line. Reason: it is spent by the command that carries it, and one paste is the point.
- Unix names are
qr-Sandqj-S-N, state is under/var/lib/quarry-runnerand/etc/quarry-runner. Reason: one prefix an admin can find and remove by hand. - A stopping runner hands jobs back; it does not drain. Reason: a deploy must not wait for the longest job.
- Anonymous visitors read a check's name and state on a public repository, and do not read CI steps or logs unless its admin opens them. Reason: under Reading CI.
- No new who-am-I route;
me.accountnames the token. Read audit is daily counts per token and repository, kept 90 days, not a row a read. Reason: under Who am I. - Setup makes a new agent only; a name that is already an account is refused. Reason: naming an existing agent silently replaced its role.
Appendix A: the zit proposal
To be filed at danmestas/zit by the owner or on his word. Not filed.
Title: Save a sync login from a forge's credential pickup, so a password never reaches the terminal
Problem. The only way to give zit a login is
--user Uwith the password in--password, inZIT_PASSWORD, or typed at a prompt. An agent session setting up a repository therefore handles the password itself: it reads it from an API answer, holds it in a variable or a file, and passes it to zit. On 2026-11-12 that meant a token typed by hand into a Keychain prompt. A private repository adds a second problem: the clone itself needs the login, so there is no checkout to save one in yet.Proposal. Three small additions.
zit pickup new. Makes 32 random bytes, saves them at$XDG_STATE_HOME/zit/pickup/<code>(directory 0700, file 0600), where<code>is the lowercase hex SHA-256 of the bytes, and prints the code and nothing else. The code is not secret.zit pickup listandzit pickup rm CODEmanage leftovers.zit login --pickup CODE [URL] [--wait DURATION], in a checkout. URL defaults to the saved remote and otherwise becomes it.zit clone URL DIR --pickup CODE [--wait DURATION], which logs in first and clones authenticated.The exchange.
POST <URL>/pickupwith headerAuthorization: Pickup <key, base64url, unpadded>and an empty body. The server answers:
202,{"state":"pending"}: not approved yet. With--wait(default 15 minutes,0for one try) zit asks again every five seconds.200,{"result":{"agent":USER,"remotes":[{"url":...,"project_code":...}]}, "secret":{"value":PASSWORD}}: zit derives the shared secret from the project code, USER and PASSWORD exactly as an authenticated push does, saves it for the saved remote at 0600, and deletes the key file. It printslogged in to URL as USER. It never prints PASSWORD, never writes it to disk, and never accepts it on argv.410,{"state":S,"error":WORDS}: denied, failed, lapsed or already collected. zit prints the words, deletes the key file, and exits 1.404or409: no such pickup, or it is for another repository. zit prints that and keeps the key file.zit refuses the exchange over plain http except to loopback, follows redirects by the rule push already uses, and sends the key to the remote's own host only.
What does not change. The default check-in user. A pickup saves who the checkout pushes as, not who its check-ins are by, so a person can commit under their own name and push through an agent's login.
Exit status. 0 logged in; 1 refused, lapsed, or still pending when the wait ended (the message says which); 2 usage.
Why in zit. The alternative is a wrapper that fetches the value and feeds it to
zit push --user, which puts the password back in a process's environment and in whatever wrote the wrapper. The exchange is one POST and reuses the secret derivation and the saved-login file zit already has. Nothing in it is specific to one forge: any server may answer<URL>/pickup.Server side. quarry RFC 0030, Credential pickup.