- 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](#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](0027-ask-your-person.md#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](0029-onboarding.md#agent-pushes) and
  [Your own runners](0029-onboarding.md#your-own-runners); RFC 0019
  [isolation](0019-workflows.md#a-runners-jobs-must-not-share-a-unix-user)
  (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](#appendix-a-the-zit-proposal).

## 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:

1. 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.
2. A repository agent took three places: Settings, Agents for the
   name; `/me/agents/NAME` for the token; a role elsewhere. The refusals
   on the way hid that the account did not exist yet.
3. 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.
4. The first push of existing local history into the empty repository
   was refused, and the work was redone in a fresh clone.
5. 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.
6. The session's first read of CI runs on a public repository was
   refused.
7. 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](#agent-setup-by-one-approval) |
| 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](#agent-setup-by-one-approval) |
| 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](#agent-setup-by-one-approval) and [Pickup](#credential-pickup) |
| 4 | First push of existing history refused | RFC 0029 item 1, not built | [First push](#the-first-push-of-existing-history) |
| 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](#install-codes) |
| 5b | Users, sudoers, directories, unit, env file by hand | Nothing | Uncovered. [A runner by one command](#a-ci-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](#reading-ci-on-a-public-repository) |
| 7 | A read-only client for private repositories | Nothing | Uncovered. [Agent setup](#agent-setup-by-one-approval), 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`.

```json
{"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 holds `a` on 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 hold `a` there: the agent is the
  approver's own, scoped to `repo: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 to
  `repo:R` for one repository and to the site for several. It is
  granted `reader` on each repository named. A client reading several
  of a person's repositories belongs to the person, not to any one of
  them.
- `belongs_to` overrides the first rule towards `person`. It cannot
  make a repository agent where the approver holds no `a`.

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.setup` by "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.grant` raises.
- The token's name is `setup` and the request's short id, so
  `agent.token.list` shows where it came from.
- Collection and lapse, each one more settings-log row: `token ID
  collected` or `token ID revoked: lapsed uncollected`.

**Collecting.** The operation's answer has a public half and a secret
half (`secret:"once"`, as `agent.token.new`):

```json
{"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](#credential-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.

### Starting without a credential

`agent.setup` may be asked only by an agent its person already operates,
so a client with no credential at all, a desktop app or a fresh session,
had no way to ask. It starts the same request device-style, and nothing
is filed until a signed-in person claims it.

1. **Start.** `POST /api/v1/setup/start`, anonymous, with
   `{"name","kind","pickup"}`: the agent name wanted (the account-name
   alphabet, at most 64), `read` or `push`, and a pickup code made as for
   [pickup](#credential-pickup). Nothing else is read: a body that names a
   repository, an account, a role or a reason is refused with 400, so
   free text from an unauthenticated caller reaches no page. The answer
   is `{"code","link","expires"}`: a code of eight characters in the sync
   token alphabet, shown XXXX-XXXX, the absolute address of `/setup`
   without the code in it, and the time the start lapses. A start is not a
   request: it raises no attention event, no notification, no mail and no
   settings-log row, and is in no list. It is one row (the code's hash,
   the pickup code, the name, the kind, the address it came from and the
   time) in the accounts store.
2. **Limits.** An unclaimed start lapses ten minutes after it is made.
   The forge holds at most 200 unclaimed at once and refuses the next
   with 429. One address may start five a minute and hold three unclaimed
   at once, answered 429 with `Retry-After` past that; an IPv6 address
   counts as its /64, since one subscriber holds the whole of it, and a
   start that cannot be held is refused on a read, before any write. A name that is already an account starts
   exactly as a free one does; the claim fails later, in a plain
   sentence, to the person.
3. **Claim.** A signed-in person (a session or the account's own
   password, never a token, never an agent) types the code at `/setup`.
   Unknown, lapsed, discarded, already claimed and malformed codes are one
   page, "no such code", and each one spends the account's and the
   address's budget of misses (eight and twenty in ten minutes), counted
   in the ops themselves so the API door shares the limit (429). A held
   code shows what the client asked for (the name, the kind), the code
   again, the address the start came from and how long ago (with a
   warning, as data, when it is not the network the person is on now: a
   code someone else started and sent them is the attack, so the page tells
   them to continue only for a client they started themselves, just now),
   and a picker of the repositories this person may set an agent up on,
   a row each with its visibility and owner: the ones `agent.setup`'s own ask-time checks accept
   from them as approver. Checkboxes for `read`; one choice and a role
   (developer preselected; publisher and maintainer; never admin) for
   `push`.
4. **Continue** files an ordinary `agent.setup` request owned by the
   claiming person, with the picked repositories and role, the start's
   pickup code and a reason the forge writes, and every ask-time check run
   as that person with the same refusals. Its asker reads "client of
   NAME": a label no account can have, since an account name holds no
   space, so the request says truthfully that a client with no credential
   asked, and neither an agent nor the person is made to have asked.
   Approval accepts that asker without an account behind it, and no one is
   told of the decision, since a client has no stream; it polls its
   pickup. The start is taken from the store in one transaction before
   anything is filed, so of two claims at once one wins and the other
   finds "no such code"; if the request cannot then be filed, the start is
   put back. **Discard** deletes it. The person lands on
   `/me/requests/ID`, approves or denies there, and the client collects
   as always.
5. **Collect** is unchanged. A start is not a request, so before the
   claim, after a lapse and after a discard the route finds no code and
   answers its one 404: an unauthenticated poller cannot tell a start
   that is waiting from one that never was.

### 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.get` still
  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 `Authorization` header, so it passes the
  Cloudflare rule of RFC 0029 decision 4.
- Storage: two columns on `signoff`, `pickup` (the code, unique when
  set) and `collected` (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](#appendix-a-the-zit-proposal).
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

```sh
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`, capability `a`. It refuses an
  agent as `ci.worker.token.new` does, and an agent may ask for it. Its
  `code` is `secret:"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/redeem` with
  `{"code", "host", "label"}` and no account. The forge checks that the
  maker still holds `a` and the repository is the same project, mints
  the token through the same path as `ci.worker.token.new` as 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 made` by the admin; then
  `minted "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 |

```ini
[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 again` to the task's log, and resolves the task with the
  engine's `pause` action 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's `KillMode=mixed` and `TimeoutStopSec=90` give 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 same `pause` and 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:

```json
{"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_used` is
  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.list` gains `use` for 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.account` shows a
  token only its own `last_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.

1. **A lapse revokes agent tokens.** `lapseRef` names an account's
   token as well as a worker token. Test: an approved `agent.token.new`
   left uncollected past the limit has its token revoked and one
   settings-log row.
2. **Pickup.** The two columns, `Quarry-Pickup`, the `pickup` field,
   both collect routes. Test: the key collects once and then gets
   `410`; `signoff.get` on the same request never returns the secret.
3. **`agent.setup`.** The op, `SetupAgent` in one transaction, the
   `mayAsk` rule, the preflight. Test: an approved request yields an
   agent, a grant and a token whose scopes are `sync` and whose
   repositories are the one named; a forced grant failure leaves no
   account row.
4. **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.
5. **`me.account` names the token.** Test: a call on a token answers
   its own id, scopes, repositories and ceiling; a session answers no
   `token`.
6. **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 as `reads 2, writes 1` for that repository and day.
7. **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.get` carries each check's name and state and no
   run id, a run's log gets the `401` sentence, and with the box ticked
   the same call answers the log.
8. **Hand-back on stop and at start.** Test: SIGTERM during a job
   resolves its task with `pause` and a second runner takes it within
   ten seconds; a marker left by a killed runner is resolved at start.
9. **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.
10. **`install`, `doctor`, `uninstall` as 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.
11. **`--user` and macOS.** Test: install without root, doctor exits 0
    with the no-secrets line, and a job that asks for a secret is
    refused.
12. **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.
13. **zit.** File [Appendix A](#appendix-a-the-zit-proposal) at
    danmestas/zit; when it ships, the guide's push recipe uses it.
    Test: `zit clone --pickup` against 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:

1. The owner approves; the forge does the setup. One request, one
   approval, from a phone if need be.
2. 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.
3. An agent never mints its own credentials directly; it asks.
4. 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.
5. 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.
6. The check-in author is the owner's choice per repository; the flow
   does not force the agent's name onto the timeline.
7. 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:

8. Setup is one operation, not a plan. Reason: fixed defaults, one
   transaction, and a page that can state what the credential reaches.
9. 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.
10. The read-only ceiling is `gjorzh28`. Reason: it is exactly what a
    visitor reads on a public repository, plus CI.
11. `admin` cannot 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.
12. 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.
13. 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.
14. 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.
15. 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.
16. Unix names are `qr-S` and `qj-S-N`, state is under
    `/var/lib/quarry-runner` and `/etc/quarry-runner`. Reason: one
    prefix an admin can find and remove by hand.
17. A stopping runner hands jobs back; it does not drain. Reason: a
    deploy must not wait for the longest job.
18. 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](#reading-ci-on-a-public-repository).
19. No new who-am-I route; `me.account` names the token. Read audit is
    daily counts per token and repository, kept 90 days, not a row a
    read. Reason: under
    [Who am I](#who-am-i-and-what-a-token-read).
20. Setup makes a new agent only; a name that is already an account is
    refused. Reason: naming an existing agent silently replaced its role.
21. A client with no credential may start a setup, and nothing is filed
    until a signed-in person claims it with the short code. Reason: it
    could not ask at all, and a start that files nothing and tells no one
    costs the forge a row for ten minutes.
22. Any signed-in person may claim, for repositories they may set an agent
    up on. Reason: the claim files the request as them and every check
    runs as them, so claiming grants nothing they could not already ask
    for through their own agent.
23. The client names no repositories. Reason: what an unauthenticated
    caller says must not reach a page the owner approves, and the person,
    who knows what the client is for, chooses.

## 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 U` with the
> password in `--password`, in `ZIT_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.
>
> 1. `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 list` and `zit pickup rm CODE` manage leftovers.
> 2. `zit login --pickup CODE [URL] [--wait DURATION]`, in a checkout.
>    URL defaults to the saved remote and otherwise becomes it.
> 3. `zit clone URL DIR --pickup CODE [--wait DURATION]`, which logs in
>    first and clones authenticated.
>
> **The exchange.** `POST <URL>/pickup` with header `Authorization:
> Pickup <key, base64url, unpadded>` and an empty body. The server
> answers:
>
> - `202`, `{"state":"pending"}`: not approved yet. With `--wait`
>   (default 15 minutes, `0` for 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 prints `logged 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.
> - `404` or `409`: 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.
