- Feature Name: `agent-accounts` (a machine that works on the forge is
  its own account, owned by a person, holding scoped named tokens
  instead of a password, and never more than its owner holds)
- Start Date: 2026-10-02
- Status: Draft. Ten decisions settled by the owner on 2026-10-02 (see
  [Decisions](#decisions)). Rung 2 of the build order (the token table
  and the resolver) is built; the rest is not.
- Depends on: RFC 0004 (capability letters are the native model; §6.3
  is the sync credential's derivation); RFC 0008 (roles over the
  letters; the `a` floor for granting); RFC 0018 (teams, for the
  team-owned agent this RFC plans and defers); RFC 0019 (environments
  and secrets, where a CI job's identity lives); RFC 0001 §8 (accounts
  are forge-local OLTP state) and §11 (the disposability test, which
  nothing here may break)
- Amends: `account.agent` (docs/api.md): the mark is no longer a flag on
  an ordinary account but the shape of a distinct kind of account;
  `me.sync.token` and `me.sync.token.revoke`: minting no longer retires
  the previous credential, see [Migration](#migration); RFC 0019 §The
  file (a `permissions:` key, see [Run tokens](#runs)) and §Environments
  and secrets (the bridge delivers a run token beside the secrets); RFC
  0008's presets (one more, Publisher, see
  [Presets](#presets)); the agent guide at `/llms.txt`, which today
  says "there is no separate bot identity"
- Precedent: `internal/oltp/agent.go` (the `operator` column: which
  accounts are machine-operated and who answers for them);
  `internal/oltp/synctoken.go` (a purpose-made, revocable push
  credential, sealed at rest); `internal/oltp/reset.go` (one-time
  tokens, hashed, spent on use, built on the invite table)
- Terms: in this RFC **operator** means the person answerable for an
  agent, which is what the `operator` column already means. The person
  who runs the forge is called the **site admin** throughout, to keep
  the two apart; elsewhere in the codebase "operator" sometimes means
  the site admin.

## Summary
[summary]: #summary

An agent is an account of its own, marked as a machine, owned by one
person. It has no password. It holds any number of **tokens**, each with
a name, a scope (sync, API, web; which repositories; a ceiling of
letters) and an expiry, and revoking one leaves the rest working. Its
effective letters on a repository are never more than its operator's:
the intersection of its own grant, its operator's effective set and the
token's ceiling, resolved at read time so a letter the operator loses is
gone from the agent in the same instant. Any person makes agents for
themselves from their own settings page, and grants them up to their
own letters on any repository they reach, without a second step. An
agent reaches the web UI
through a one-time sign-in link its token mints, which a headless
browser opens like any URL; the session it gets is short, bound to the
token, and ends when the token does. History shows the agent, with the
operator named, and the forge's own records show which token did what.

A CI job is not an agent and gets no account. A workflow declares
`permissions:`; for a run its environment admits, the forge mints a
**run token** on the same machinery (one repository, at most the
declared letters, alive only while the run is), delivered through the
bridge the way RFC 0019 delivers secrets and never stored past the run.
History records the run and who started it, not a user.

The forge has agents of its own. A forge agent is made by a site admin,
operated by the forge with a named site admin answerable for it, may
hold `s`, and is otherwise an agent: no password, tokens, the same web
sign-in, a badge of its own. zeitgeist becomes one.

## Motivation
[motivation]: #motivation

Four things happened on zeitforge in the last month, each a symptom of
the same gap: the forge has one kind of account, built for a person
with a password, and the machines were squeezed into it.

**One account, two sessions, one credential.** Two agent sessions
worked all day as `zeitgeist`. The account holds exactly one sync token
(`sync_token` is keyed on `user`, and `MintSyncToken` replaces), so
when one session minted a fresh one the other session's pushes started
failing with nothing to say why. The fix in practice was a memory rule
telling every session never to mint, which is the forge pushing a
constraint it should hold onto the people using it.

**The API takes the password or nothing.** The `api.Verify`
implementation (cmd/quarry/main.go) resolves HTTP Basic through
`Authenticate`, which checks the password and the legacy fossil
credential. The sync token is refused there by design (it "is useless
for signing in"); only the sync lane's first round takes it
(`syncIdentityCtx`, cmd/quarry/registrar.go). So an agent that wants
the JSON API is handed the account's real password, the one credential
that can change the email, reset itself and sign in anywhere. The start
page says so plainly: "signs in with your account name and password
over HTTP Basic", and `/llms.txt` says the same. Every prompt that
mentions the keychain is a workaround for this.

**A least-privilege CI identity took two administrators and was the
wrong shape.** The release job (RFC 0020) needed an identity holding `y`
on quarry and nothing else. Creating `zeit-release` needed a site admin
(`account.add` is `s`), and granting it `y` needed the repository's
admin, because since #318 a site admin deliberately holds no repository
letters. The person who wanted it was both, and it still took two acts
in two places. Worse, the result was a standing account with a standing
credential for a job that runs for ten minutes a month: a credential
that outlives every run it was made for is a key left in the door. The
answer is in [Run tokens](#runs): a job declares what it needs and the
forge mints a credential that lives as long as the run.

**No lifecycle.** Nothing expires, nothing rotates, and no page answers
"which agents are mine", "which of them can still push", or "stop all
of them now". A token minted in July is as live as one minted this
morning. When the practice is to run several sessions a day, the
absence of a list is the absence of control.

The `operator` column (agent.go) was the first step: it made the forge
able to say which accounts are machines. This RFC is the second: it
makes a machine account a different shape, so that the problems above
stop being possible rather than merely discouraged.

## Decisions
[decisions]: #decisions

Settled by the owner on 2026-10-02. The rest of this RFC designs within
them.

1. **Identity: an agent is its own account, owned by one person, its
   operator.** History shows the agent, with the operator named. One
   account per agent role (a release job, a coding assistant), not per
   chat session; a role runs any number of sessions at once on separate
   tokens.
2. **Credentials: scoped, named tokens.** An agent holds any number of
   tokens. Each has a name ("quarry session 2"), a scope (sync and/or
   API, which repositories, a ceiling of letters) and an expiry.
   Revoking one leaves the others. This replaces one-sync-token-per-
   account and agents using the account password for the API.
3. **Permissions: an agent never holds more than its operator.** Its
   effective letters on a repository are the intersection of its own
   grant and its operator's. The operator grants their agent up to their
   own letters on any repository they reach, with no separate
   repository-admin step (first worded as "repositories they
   administer"; widened the same day, see below). An operator losing a
   letter takes it from their agents.
4. **Creation: self-serve.** Any person creates agents for themselves
   from their own settings. Site admins may cap the number per person or
   disable it.
5. **Web: agents may use the web UI and have no password.** A
   passwordless way to a web session is designed in [Web
   sign-in](#web), with the operator's approval as the gate.
6. **Teams: planned, deferrable.** Team-owned agents (RFC 0018) are
   designed in [Team-owned agents](#teams) and built later.
7. **CI jobs are not accounts.** A workflow declares `permissions:`
   (GitHub Actions' word). For a run its environment admits, the forge
   mints a run-scoped token: one repository, at most the declared
   letters, valid only while the run is live, delivered to the job the
   way RFC 0019 delivers secrets, never stored past the run. History
   records the workflow run and who started it, not a user. It uses the
   agent token machinery. This replaces the plan to give the release
   job a service account. Agent accounts are for agents that act on
   their own, under an operator; jobs get [Run tokens](#runs).
8. **An agent may hold everything on a repository but the deed.** It
   can be given `a` (settings, people, mirrors, environments and
   secrets, protection, webhooks, bucket settings, accepting landings),
   short of the acts that belong to the deed holder rather than to any
   letter: deleting the repository, stopping its serving, and handing
   its deed on. An agent never holds or acts on a deed, whatever its
   letters. See [What an agent may hold](#holds).
9. **One new role preset, for people and agents alike.** Beside RFC
   0008's Reader, Developer, Maintainer and Admin: **Publisher**,
   Reader's letters plus `y` (reads, and publishes downloads and
   release files; no code, tickets or settings). Admin stays the top
   role and stays as it is: managing settings does not make you a
   publisher, which is fossil's rule. An agent that needs Admin and
   publishing is given both, as two grants, not a combined preset.
   (A combined "Steward" preset was drafted and dropped: steward is
   reserved for mirrors, where the mirror steward is the person who
   maintains the mirror's credentials on the remote fossil server, and
   it is not a role to hand a machine.) See [Presets](#presets).
10. **The forge has agents of its own; zeitgeist is one.** A **forge
    agent** is created only by a site admin; its operator is the forge,
    with a named site admin answerable for it, shown on its page.
    Unlike a person's agent it may hold `s` when a site admin grants
    it. It still has no password: tokens only, the same token model,
    the same web sign-in through one-time links minted by its own
    web-scoped token. It is badged distinctly ("forge agent") wherever
    agents are badged. zeitgeist migrates to a forge agent and keeps
    site admin; its password is retired once its tokens are in place.
    Its edges are settled too: a forge agent may hold and act on deeds,
    with any site admin able to take them; it has no operator ceiling;
    there is no cap and it is listed on `/fleet`; any site admin
    manages it; and it never creates or alters another forge agent or
    anyone's `s`. See [Forge agents](#forge-agents).

Also settled the same day, on questions this draft raised:

- An operator may delegate up to their own letters on **any**
  repository, not only ones they administer ([Granting](#granting)).
- A person's agent is never a site admin, whoever its operator is
  (a forge agent may be, by decision 10).
- `repo.new` by an agent puts the deed on its operator.
- People get the same multi-token list; minting adds rather than
  replaces ([Migration](#migration)).

## The model
[model]: #the-model

### An agent is an account with no password
[agent]: #an-agent-is-an-account-with-no-password

```
-- account gains nothing new: an agent is a row with
--   operator <> ''   (agent.go, already stored)
--   pwhash = ''      (never set, never settable)
-- Pending, which today reads pwhash = '', becomes
--   pwhash = '' AND operator = ''
-- so an agent is not reported as an unclaimed invite.

CREATE TABLE IF NOT EXISTS token (
  id          TEXT PRIMARY KEY,   -- short public id, in the token's prefix
  account     TEXT NOT NULL DEFAULT '',  -- the agent (or a person);
                                         -- '' for a run token
  run         TEXT NOT NULL DEFAULT '',  -- the run, for a run token; else ''
  name        TEXT NOT NULL,      -- "quarry session 2"; shown, never secret
  verifier    TEXT NOT NULL,      -- sha256 hex, OR sealed (see Storage)
  sealed      INTEGER NOT NULL,   -- 1 when verifier is the sealed value
  sync_secret TEXT NOT NULL DEFAULT '',  -- a run token's one derived
                                         -- §6.3 secret; cleared at revoke
  scope       TEXT NOT NULL,      -- words, comma-separated: sync,api,web
                                  -- (letters would collide with caps)
  repos       TEXT NOT NULL DEFAULT '',  -- '' = every repository the
                                         -- account may reach; else names
  ceiling     TEXT NOT NULL DEFAULT '',  -- '' = no ceiling; else a cap string
  created     TEXT NOT NULL,
  expires     TEXT NOT NULL,      -- required; see Lifecycle
  last_used   TEXT NOT NULL DEFAULT '',
  revoked     TEXT NOT NULL DEFAULT ''   -- when; the row stays for the audit
);
CREATE INDEX IF NOT EXISTS token_by_account ON token (account);
CREATE INDEX IF NOT EXISTS token_by_run ON token (run);
```

One column is added to `account`, `answerable TEXT NOT NULL DEFAULT
''`, used only by [Forge agents](#forge-agents), whose operator is the
reserved name `forge`. For a person's agent nothing is added: the two
facts that make a row an agent are both already there, and a schema
that needed a third would be saying the first two do not mean what
they say. `Authenticate` already
refuses an account with no hash (an unclaimed invite falls through its
checks), so an agent cannot sign in with a password because there is
none to match, not because a branch says `if agent`.

Agent names come from the account namespace, as team names do (RFC
0018), because a grant row names a string and a reader of the People
page should not have to know what kind of thing a name is. The
convention `<operator>-<role>` (`dmestas-release`) is suggested in the
form and not enforced.

An agent has an email of `''` and never gains one: invites and resets
are the two things mail is for, and an agent has neither. Mentions of an
agent land in the agent's own inbox (`me.feed`, readable with an API
token) and nowhere else; the operator sees their agents' names on their
own settings page, which is where "what are my machines doing" is
asked.

### Tokens
[tokens]: #tokens

A token is the only credential an agent has. One token is one thing a
machine holds: a session of a coding assistant, a release job, a
mirror's pull. Its parts:

| part | meaning |
|---|---|
| name | for the list; the operator's words, not the forge's |
| scope | any of **sync** (the xfer front: clone, pull, push), **api** (`/api/v1`), **web** (pages: HTTP Basic on them, and minting a sign-in link) |
| repositories | the names it may act on; empty means every repository the agent reaches. A name here grants nothing, it narrows |
| ceiling | a cap string; the token's effective letters are intersected with its expansion. Empty means the agent's own letters, no narrower |
| expiry | required, at most one year out, 90 days by default |

The value is `qat_<id>_<secret>`: a fixed prefix so a secret scanner can
recognise one in a log or a commit, the row's id so the forge finds the
row without trying every token, and 160 bits of randomness in the sync
token's existing alphabet (Crockford base32, no padding). It is shown
once, at minting, on the operator's page or in the API response, and
the page says so in the words the Push credential section already uses.

**Where each scope is checked.** Four doors, one resolver:

- `/api/v1`: HTTP Basic with the agent's name and the token as the
  password. The `api.Verify` implementation (cmd/quarry/main.go) gains
  a branch before `Authenticate`: a password shaped like a token is
  looked up by its id, compared by hash (or unsealed and compared in
  constant time), and checked for the **api** scope, expiry, revocation
  and the request's repository against `repos`. A token without **api**
  is refused here exactly as the sync token is today. A password not
  shaped like a token takes the path it takes now; an agent has no such
  path.
- Web pages: the same resolver behind `web.go`'s Basic path, for
  **web** scope. A session cookie is obtained as [Web sign-in](#web)
  says.
- The sync lane's Basic round: a clone's first request has no project
  code to sign a card with, so the client answers the 401 with Basic
  and the credential it holds in the password slot, and
  `syncIdentityCtx` (cmd/quarry/registrar.go) accepts the sync token
  there today. It accepts a **sync**-scoped token the same way (by id,
  then hash or unseal), and a `run-<id>` login with its run token.
- The xfer front proper: the login card carries `USER NONCE SIGNATURE`
  and the client signs with SHA-1 over the nonce and the §6.3 shared
  secret; the card never carries the token. `oltp.Secret` today answers
  a hand-issued `sync_user` row first, then unseals the one sync token
  and derives the repository's secret. It keeps the `sync_user` order
  and becomes, for tokens, a loop over the account's live
  **sync**-scoped tokens that name (or do not exclude) this repository,
  deriving each one's secret; `verifyLogin` is tried against each, with
  `subtle.ConstantTimeCompare` in place of today's `==`, and the first
  match names the token the push came in on. The loop is bounded by the
  per-account token cap ([Rate limits](#limits)), which is why there is
  one.

One rule at the front changes. `oltp.Secret` refuses an account without
`i`, so today a Publisher, or any token ceilinged to `y`, cannot push
unversioned content at all: the credential is refused before the cards
are read. The gate moves to the cards, where it already half lives
(`uvPushOK` checks `y` per uv card): `Secret` answers for an account
holding `i` **or** `y`, and a push whose login resolves to `y` without
`i` is accepted only while every card it carries is a uv card, refused
whole the moment a check-in card appears. That is what makes the
Publisher preset and the release run token usable over the sync
protocol.

**The letters a token resolves to.** On repository R, for an agent A
with operator O presenting token T:

```
public(R)     = R's nobody category (what a visitor holds)
own(A, R)     = A's explicit string on R, categories expanded (RFC 0004),
                the anonymous category included: an agent is signed in
effective(O,R)= O's effective set on R, exactly as the gates compute it
                today (grants, teams, categories)
ceiling(T)    = expansion of T's cap string, or everything if ''

effective(A, R, T) = public(R)
                   ∪ ( own(A, R) ∩ effective(O, R) ∩ ceiling(T) )
```

Three things to read off it. A ceiling never takes away what the public
has, so a token ceilinged to `y` can still clone the repository it is
about to push unversioned content to; stripping `g` from a push token
would be a token that cannot be used. The site admin flag does not flow:
it is not a letter, `s` lives at forge scope only (#318), and a person's
agent is never a site admin whoever its operator is (a forge agent is
the exception, under its own rules in [Forge agents](#forge-agents)).
And nothing is stored: the
intersection runs where `EffectiveCaps` already runs, one extra lookup
of the operator's set, so a letter the operator loses at noon is gone
from every token at noon, which is what decision 3 asks and what RFC
0018 chose for teams for the same reason.

On a private repository `public(R)` is empty, so an agent reaches it
only through a grant its operator also holds. On a repository under a
team the operator is in, `effective(O, R)` carries the membership floor
and the team row, so the agent inherits the floor through its operator
without being a member; whether an agent may be a team member in its own
right is left to [Team-owned agents](#teams).

### What a token may not do
[token-limits]: #what-a-token-may-not-do

Three rules hold at every door, and the build enforces each in one
place so that no path can skip it.

**The person's own account is closed to a token.** Everything under
`me.*` that manages the account itself refuses a request that came in
on a token: minting, listing and revoking credentials, the password,
the email, sessions, notification preferences and push subscriptions.
A token is one thing a machine holds, cut to a ceiling and a list of
repositories, and any of those acts would let it reach past both to
the account. They are a person's acts, signed in with a password or a
session. Ops gated on a repository's letters (CI tokens and secrets,
settings, grants and the rest) stay open to a token that holds those
letters there, within its ceiling and its repositories: the letters
already say what it may do. Follows, mutes and saved views stay open
too, since they grant nothing and show nothing past the token's own
reads.

**The site-admin flag never flows to a token.** It is not a letter, a
token never carries `s`, and every operator path (marking another
account, a site admin's deed acts, server-side fetches) answers a
token as it answers someone who is not a site admin, whoever minted
it.

**A token with a list of repositories does nothing at forge scope**,
where no repository is named, except read its own identity
(`me.account`). A list would mean nothing if search, people, new
repositories, teams and the rest stayed open to it. The rule lives in
`ops.Registry.Invoke`, the one dispatch the API router and the pages
share. Whether an op is about a repository comes only from its
declaration, a `{repo}` in its route or a declared repository scope,
and never from its body: reading a generic `name` field would let a
token limited to `X` create a repository called `X`.

### Granting
[granting]: #granting

Today `account.grant` is an `a` op: whoever administers the repository
writes any account's string. It still works on an agent, and the
intersection keeps the result honest whatever string the admin writes.

The new route is the common one. `agent.grant` lets an operator write
their own agent's string on **any repository the operator can reach**,
administered or not, with one check: the string is clipped to the
operator's effective letters there before it is stored. The agent
cannot exceed the operator, so there is nothing a repository's admin
could refuse on the operator's behalf, and a contributor holding `i` on
somebody else's repository can put their own agent to work there
without asking anyone, which is the case the literal reading of
decision 3 would have left to a second administrator. The web form is a
row on the operator's "Your agents" section with the role editor the
People page already uses (RFC 0008). Writing `developer` on an agent
whose operator holds `reader` stores `reader` and says so; and because
the intersection also runs at read time, a letter the operator loses
later is gone from the stored string's effect without a rewrite.

A repository's People page lists an agent granted this way among every
other route in (RFC 0018 made that page a report of routes), with the
badge and the operator's name, so the repository's admins see who put a
machine on their repository and can revoke it with `account.revoke` as
they would anyone.

This is what a person's own agent needs: the person who administers
quarry makes `dmestas-coder`, writes `developer` on it, mints a token
scoped to `quarry`, and is done, with no site admin and no second
page. The release job is not this case; it is [Run tokens](#runs).

### What an agent may hold
[holds]: #what-an-agent-may-hold

Everything a letter reaches, up to and including `a`: settings,
people, mirrors, environments and their secrets, protection, webhooks,
bucket settings, accepting landings. An agent that administers a
repository for its operator is a legitimate shape (a release manager,
a triage bot that grants `reader` to newcomers), and refusing `a` to
machines would only move the work back to the person's password.

What it may never hold is the **deed**. The split already exists in the
code: `repo.delete` and `repo.remove` are gated on the deed holder, not
on `a` ("the `a` letter reaches everything inside a repository and
stops at ending it"), and `repo.claim` hands a deed from its holder to
one of the repository's admins. Those three are the deed-only acts
today, with one gap each that this RFC closes for agents: a repository
nobody has claimed is deletable and removable on `a` alone ("somebody
must be able to retire it"), and `repo.claim` also takes an unheld
deed. An agent is refused by all three **before** the deed is
consulted, held or unheld, so an agent with `a` on an unclaimed
repository can neither retire it nor claim it; retiring an unclaimed
repository stays a person's act. The rule is made explicit at the one
place a deed is written as well: `SetOwner` refuses a person's agent
(`operator <> ''` and not `forge`), so such an agent cannot receive a
deed by claim, by transfer, by `repo.new` (the deed goes to its
operator) or by a migration's guess. A forge agent is the exception,
for the reasons under [Forge agents](#forge-agents). A site admin's operator paths (`repo.delete` and
the rest, passed on forge-scope `s`) do not reach an agent either,
since an agent never holds `s`.

Two guards for an agent holding `a`, settled with decision 8, so that
it can neither lock its operator out nor escalate anyone:

- `account.grant` and `account.revoke` by an agent **cannot lower or
  remove** the letters of its own operator or of the repository's deed
  holder. The refusal names the person.
- An agent **cannot grant anyone more than its own effective letters**
  on that repository. An agent holding `a` through a `developer`
  ceiling writes `developer` at most; an agent whose operator holds
  `a` but not `s`-class letters writes nothing it does not itself
  resolve to.

Both are checks inside the two ops, keyed on the caller being an agent,
and neither changes what a person may do.

### Presets
[presets]: #presets

RFC 0008's four presets were written for people on a forge with no
machines. The shape this RFC makes common that none of them fits is a
bot that uploads files: nothing below Admin publishes, and Admin stops
short of `y`. One preset is added to `internal/oltp/roles.go`, beside
the four, for people and agents alike:

| preset | letters | who it is for |
|---|---|---|
| Reader | `bcghjoprz2378BC` | unchanged |
| Developer | `uv3bBC78` | unchanged |
| Maintainer | `uv45bBlqCA78` | unchanged |
| Admin | `a` | unchanged: settings and people, not publishing |
| **Publisher** | Reader's letters plus `y` | reads, and publishes downloads and release files; no code, tickets or settings |

An agent that runs a repository for its operator and also publishes is
given Admin and Publisher's `y` (and `x` for private branches, where it
needs them) as grants of their own, read back as "Admin, and can
publish". No preset names that combination: "steward" is reserved for
mirrors, where the mirror steward is the person who maintains the
mirror's credentials on the remote fossil server, and a preset holding
everything short of the deed invites handing it to a machine by
default. Whatever it holds,
[What an agent may hold](#holds) keeps the deed acts out of an agent's
reach.

Which preset for which case, as the reasoning:

| scenario | preset |
|---|---|
| your own agent running settings, people, CI and tokens | Admin |
| that agent, also publishing builds | Admin, and can publish (`y`) |
| a settings and people manager who should not publish | Admin |
| a bot uploading nightlies or generated docs | Publisher |
| cutting releases | no role: the release workflow's run token |
| triage or CI-status bots | Developer or Reader, as today |

The roles editor (RFC 0008) offers five presets instead of four, the
role-change sentences gain the new name, and `RoleOf`'s nearest-preset
reading (`Infer`) takes Publisher into account and reads `ay` as
"Admin, and can publish".

### Creating an agent
[creating]: #creating-an-agent

`agent.new` (name, a one-line description) is a signed-in op. The
caller becomes the operator. A site setting `agents_per_person` (an
integer; `0` disables creation for everyone but site admins) is checked
on the way in; the default is ten. A site admin may create an agent for
anybody (`agent.new` with `operator`), which is how an existing marked
account gets a proper operator during [Migration](#migration).

`agent.list` answers the caller's own agents with each one's tokens
(names, scopes, created, expires, last used, never the values) and the
repositories each resolves on; a site admin may ask for anybody's.
`agent.delete` ends every session, revokes every token and removes the
row, the same cascade `DeleteAccount` runs for a person; the agent's
check-ins and tickets stay in the DAG under its name, as a deleted
person's do.

`account.agent` stays for what it does (mark, name the operator, clear
the mark by a site admin), with two changes. **Marking clears the
password.** Today any account may declare itself an agent and keep its
password, which under this RFC would be an agent with the one
credential an agent must not have; so from rung 3 marking an account
ends its sessions and sets `pwhash = ''` in the same transaction, and
the form says so before the button. Accounts marked before rung 3 are
grandfathered until cutover ([Migration](#migration)). **Clearing the
mark mints an invite.** A site admin clearing the mark on a passwordless
account would leave a row nobody can sign in as and nothing can mint
for, and `ResetPassword` refuses a passwordless account by design
("invite it instead"); so clearing the mark mints an invite link in the
same act, through the invite path that is permitted once the mark is
gone, and returns it. Nothing is stranded between the two states.

Two refusals follow for the one-time-link ops. `account.invite` treats
any `pwhash = ''` row as pending today and would mint a set-a-password
link for an agent; `account.reset` would refuse it only by accident.
Both refuse an account with `operator <> ''` by name, because a
password is the thing an agent cannot have.

### Forge agents
[forge-agents]: #forge-agents

A person's agent answers to a person and is bounded by them. The forge
also runs machines that answer to nobody in particular and to the site
as a whole: the account that lands RFC text and files tickets on the
forge's own repositories, the one that runs a mirror sweep, the one a
test suite creates and deletes repositories with. zeitgeist is one, and
it holds site admin because the work needs it. Squeezing it into the
person-agent model would mean a person as its ceiling, which is the
wrong answer in both directions: no person should have to hold `s` so
that a machine can, and a machine's `s` should not vanish when a person
leaves.

So a **forge agent** is a second kind, settled by decision 10:

- **Made by a site admin only.** `agent.new` with `operator: forge` is
  an `s` op. The name `forge` is reserved like `run-`: no account or
  team may take it, and `SetOperator` accepts it only from a site
  admin.
- **The operator is the forge; a site admin is answerable.**
  `account.answerable` names the site admin who answers for it, shown
  on the agent's page beside the badge and required at creation. Any
  site admin may change it, and `account.disable`, `account.delete` or
  the loss of the flag on the answerable admin do not touch the agent;
  the fleet page shows the agent as "answerable: nobody" until a site
  admin names one, which is the one state a forge agent can be in that
  a page complains about.
- **It may hold `s`**, when a site admin sets the flag as for any
  account. It holds repository letters by grant, like any account; the
  flag gives it none (#318).
- **No password, tokens only**, the same `token` rows, scopes,
  ceilings, expiries, the same four doors, the same one-time sign-in
  link minted by its own web-scoped token. Marking an account as a
  forge agent clears its password as marking does for any agent.
- **Badged "forge agent"** wherever an agent is badged, with the
  answerable admin in the title where a person's agent carries its
  operator.

The edges, settled by the owner on 2026-10-02 as part of decision 10:

- **The ceiling is its grants, not a person.** `effective(A, R, T)` for
  a forge agent drops the operator term: `public(R) ∪ (own(A, R) ∩
  ceiling(T))`, with `s` at forge scope when the flag is set. "Never
  more than its operator" has no person to point at, and the forge
  holds everything, so the ceiling is whatever a site admin granted,
  narrowed per token exactly as for anyone.
- **It may hold and act on deeds.** `SetOwner` accepts a forge agent;
  `repo.new` by one deeds the repository to the agent itself;
  `repo.delete`, `repo.remove` and `repo.claim` treat it as the account
  it is, deed-gated like a person and, when it holds `s`, passed on the
  operator paths with the same receipt to the owner's forge log. A
  repository a forge agent holds is the forge's: any site admin may
  take the deed with `repo.claim` as from an unheld repository. This is
  what the test suite and the RFC-landing work already do as zeitgeist,
  and refusing it would move those acts back to a person's password.
  A forge agent that holds a deed binds a bucket as any deed holder
  does, which on zeitforge is the forge's own bucket.
- **No cap, and listed on `/fleet`.** `agents_per_person` does not
  apply; the fleet page gains a "Forge agents" section listing each
  with its answerable admin, flag, token count and last use. The public
  profile shows "forge agent", the answerable admin and the
  description, and nothing about tokens.
- **Any site admin controls it.** Mint, revoke, `revoke_all`, grant,
  reassign the answerable admin, disable, delete: every act a person
  takes on their own agent, any site admin takes on a forge agent, from
  the fleet page. The answerable admin is a name to ask, not an
  exclusive key; a forge with one site admin on holiday must still be
  able to stop a runaway machine.
- **Guards.** The two `a` guards for a person's agent (cannot lower its
  operator or the deed holder; cannot grant above its own letters)
  apply with the answerable admin in the operator's place for the first
  and unchanged for the second. A forge agent holding `s` may do what
  a site admin may, `account.add` included, except make or alter
  another forge agent or change any account's `s` flag: those stay
  with a person, so that a machine cannot multiply machines or grant
  itself a successor.

## Web sign-in
[web]: #web-sign-in

An agent needs a web session for two reasons that come up in practice:
the UI is verified by looking (a standing rule), and some surfaces are
forms before they are ops. It has no password, so `/login` is not for
it. Three ways were weighed.

**OAuth 2.0 device authorization grant.** The agent asks the forge for
a user code, shows it, and polls; the operator opens a page in their own
signed-in browser, types the code, approves; the poll then returns the
session. Its merit is that every session is a fresh human act, and it is
the right shape when the device has no credential at all. That is also
its cost here: an agent that runs unattended, which is the case this
forge actually has (two sessions working all day, a release job at
night), cannot wait for a person at each session start, and would end
up holding one long session to avoid asking, which is the standing-key
shape sessions are kept short to prevent. It also needs three new
things (a pending-approval table, a polling endpoint, a code-entry page)
to say what the operator already said when they minted the token.

**A one-time sign-in link minted by a token.** `POST
/api/v1/me/signin-link` with a **web**-scoped token returns a URL good
for one `GET` within two minutes. Opening it sets the session cookie and
redirects to `next` or `/`. The operator's approval is the token: they
minted it, in their own signed-in browser, with web scope, and they can
revoke it. The shape exists: the invite table holds one-time,
hashed-at-rest, spent-on-use, self-expiring tokens for invites and
resets. A sign-in link is the same shape in **its own table**,
`signin_link (token, account, token_id, next, created, expires, used)`,
not a third `kind` on `invite`. The reason is what the invite readers
do: `InviteFor` and `RedeemInvite` never look at `kind`, so a sign-in
row on that table would redeem as an invite and set a password on the
agent, which is the one thing an agent must not get. A filter would
fix that today; a separate table makes the mistake impossible for every
reader written later, and the invite path's refusal of agents (above)
stays a single rule rather than one with an exception. The redemption
opens a session instead of setting a password, with a two-minute TTL.

**The token as the cookie.** Accept `qat_…` as a `quarry_session` value
directly. Rejected: the session table is the audit surface (`Sessions`
lists who is signed in from where, and `EndAccountSessions` is how a
reset or a disable takes effect), and a cookie that is a long-lived
credential sitting in a browser profile is the thing the hashed, dated
session row was built to avoid.

**Recommendation: the one-time link.** It reuses a primitive the forge
already trusts, it needs no human per session, and the approval is
exactly as strong as the device grant's because both reduce to "the
operator, signed in, said yes"; the link just lets the operator say it
once per token rather than once per session. The device grant is not
proposed; if a case appears where an agent must obtain a session with
no credential at all, it can be added beside the link without changing
anything here.

How a session obtained this way differs from a person's:

- **Lifetime: 24 hours**, not `SessionTTL`'s fourteen days. A machine
  that needs a session tomorrow mints another link; it costs one
  request. A new `token_id` column on `session` binds the row to the
  token, and the device list joins it to show the token's name ("quarry
  session 2") beside the User-Agent, which is what the existing `agent`
  column holds and keeps holding.
- **Bound to the token.** Revoking or expiring the token ends every
  session it opened, in the same statement. Revoking the token is the
  one act an operator needs to know.
- **No second factor of trust.** A session from a link holds the token's
  ceiling and repositories, carried on the session row and applied by
  the web gates where they apply the letters. A web session is never
  wider than the token that made it.
- **The link is spent on first `GET`**, whatever the response, and
  unknown, spent or late links are one page saying so, with no
  redirect, so a scanner following links learns nothing.
- **The URL is a secret for two minutes and then nothing.** It can
  land in a browser history, a proxy log or a `Referer` header. Single
  use and the two-minute TTL make a later copy worthless; the
  redemption response carries `Referrer-Policy: no-referrer` and
  redirects at once, so the token leaves the address bar before any
  page that could leak it is loaded.

**Browser automation.** The recipe that goes in `/llms.txt`:

```
URL=$(curl -s -u "$AGENT:$TOKEN" -X POST \
      "$ORIGIN/api/v1/me/signin-link" -d '{"next":"/r/quarry"}' \
      | jq -r .url)
agent-browser --session "$AGENT" open "$URL"   # one GET; cookie set
```

Headless Chrome is the same: navigate to the URL once, keep the
profile. `agent-browser` sessions are per name, which is why the line
names one; two agents on one machine must not share a profile, for the
same reason two sessions must not share a token.

## CI jobs are not accounts: run-scoped workflow tokens
[runs]: #ci-jobs-are-not-accounts-run-scoped-workflow-tokens

A CI job is not an agent. An agent acts on its own and somebody answers
for it; a job runs a file somebody reviewed, at a moment an event
chose, and stops. Giving a job an account means a standing credential
for a thing that is not standing, and a user in history where there was
no user. So a job gets a **run token**: a row in the same `token` table,
with no account behind it, that exists exactly as long as the run.

### `permissions:` in the workflow file

Quarry reads and validates the key itself and strips it from the file
before dagnats compiles the workflow, so no change to dagnats is needed.
RFC 0019's file gains GitHub Actions' `permissions:` key, at the
workflow level or on a job (a job's narrows the workflow's, never
widens; when the workflow declares none, a job's own stands, and a job
under neither gets `contents: read`). Absent nouns under a declared key
are `none`, as in Actions. Its words map onto letters, which is the only vocabulary the
gates speak:

| `permissions:` | letters | what a job can then do |
|---|---|---|
| `contents: read` | `o g` | read and clone (the default when the key is absent) |
| `contents: write` | `i` | push check-ins (a landing's own branch, say) |
| `uv: write` | `y`, plus the repository's read letters when it is private | push unversioned content: the release case |
| `tickets: write` | `c w` | comment on and update tickets |
| `wiki: write` | `k` | edit wiki pages |
| `forum: write` | `3` | post, which is how a job reaches a landing thread |
| `checks: write` | `8` | report checks on another run's subject |

A `permissions:` inside a flow-style job, and one whose value is not a
mapping, are refused the same way; stripping takes every line the key's
value spans, block scalars included.
Unknown words are errors with a line and a column, named as unsupported
rather than as a typo, like every other key in RFC 0019's `Errors`.
`write` implies `read` for the same noun. On a private repository a
uv push has to read the repository as well as publish to it, because the
sync front answers no one it cannot show the repository to, so `uv:
write` there grants `y` and the repository's read letters; on a public
one the public already reads. A job that declares nothing
gets `contents: read`, and on a public repository that is what a visitor
already holds, so most jobs mint nothing at all.

### Minting

The forge records, when a run starts, each job's declaration, its
environment and timeout, the event and the starter, beside the run.
It mints the token when the job's runner asks for it through the
bridge, moments later: the forge keeps only a hash, so the value can
be handed over only by whoever has just made it, and a job nobody runs
is minted nothing. It mints for a job whose declared
permissions exceed what the public holds on the repository, and only if
the run qualifies under RFC 0019's environment branch policy: the same
rule that decides whether a job receives a secret decides whether it
receives a token, and a job that names no environment gets the
repository's default policy (protected branches only). A landing's
merge never qualifies, whatever the file says, as with secrets. The
events that may mint are `push`, `landing`, `tag`, `workflow_dispatch`
and `schedule`; a pair never does.

The token's letters are the intersection of two things:

```
run(T) = declared(job) ∩ allowed(environment)
```

When `y` survives the intersection, the repository's read letters
`o g` are added after it, whatever `allowed` says: a `release`
environment allowing `y` alone must still yield a token that can read
the private repository it publishes to (the built rule; the table
above puts them on `uv: write` itself, which `allowed` would strip).

`allowed` is a new field on an environment and on the repository's
default policy, set by `a` (built as `allowed` and `starter` on
`ci.environment.set`, name `""` for the default policy, and on the
settings page's Environments rows), defaulting to `o g 8`: a settings page says
what a job in this environment may at most be handed, so a branch that
edits `permissions:` can ask and not take, which is RFC 0019's split
between the file (a convenience) and settings (the safeguard).

**The starter's letters are not intersected.** Settled by the owner on
2026-10-02, with the reasoning recorded: the workflow file was reviewed
and the branch policy says only reviewed code runs with a token, so a
starter cap mostly repeats those two; it would force whoever tags a
release to hold `y`, which `a` deliberately lacks (on quarry the person
who tags holds `a`, not `y`); and it would rule out scheduled runs,
which have no starter. What a starter cap was protecting is one
question, who may release, and that question gets its own field.

An environment may carry an optional **required starter letter**, set
by `a` beside `allowed`: the letter the person whose act started the
run must hold on the repository. The starter is the **authenticated pusher**, never a `U`
card, which any clone can write: the xfer front records, for each push
round the store takes, which verified login carried which artifact (the
`file` cards its login card signs; first carrier wins, kept 30 days,
table `artifact_carrier`). For `push` the starter is the check-in's
carrier, for `tag` the carrier of the tag's control artifact, for the
merge a landing's accept writes the account that accepted it, for
`workflow_dispatch` the clicker. An artifact with no recorded carrier
(a mirror's pull, a push from before the record) gives a run with no
starter: it mints only where no starter letter is required. A starter
that is itself a run's login never mints, and a run's token cannot
start a run (`ci.trigger` refuses it). If the starter lacks the letter, the run still
proceeds (the file said to run it) and mints no token, and the run's
log says which letter whom lacked. A `schedule` run has no starter. Schedules are quarry's: the
engine passes `on:` through and has no trigger that carries a check-in,
so the forge's scheduler, on the fleet lease holder, reads the default
branch tip each minute and starts what is due through the ordinary
launch, which registers that tip's definition just before it starts
(so a schedule runs the default branch's file, GitHub's rule), each
minute at most once (`ci_schedule_fire`, claimed before the launch),
no backfill. Being a default-branch run, it receives its environment's
and the repository's secrets under the ordinary branch policy, as on
GitHub. A scheduled run
mints in an environment with no starter requirement and is refused a
token, with the same log line, in one that has it. The `release`
environment on quarry requires `a`, so a tag by somebody without `a`
builds and cannot publish.

The token is `qrt_<id>_<secret>`, a prefix of its own so a log line
tells a run token from an agent's. The row carries `repos = R`, `ceiling
= run(T)`, `scope` **sync** and **api** always (a job clones its
private repository over the sync lane's Basic round, which needs sync
scope and no secret; the §6.3 secret is kept only when `i` or `y` is
in the letters), `expires` at the job's `timeout-minutes`, never past
six hours (six hours when it states none), a `run` column naming the run and a `name` naming the
job. A job holds one live token; a retried task's ask revokes the
first. It is delivered the way a secret is: the
runner, having claimed the job and proven it with its worker token,
asks the bridge's `POST /r/{repo}/ci/bridge/v1/tasks/{id}/token` and
receives `QUARRY_USER` and `QUARRY_TOKEN`, or none and a note it
writes to the job's log; the two reach every step of the job over
stdin to `quarry job-env`, `QUARRY_TOKEN` masked in the log. A runner
that runs jobs as itself cannot keep a token to the job and does not
ask for one: the job runs without, and its log says why. One asking a
forge without the route gets nothing. The launch records the run just
after the engine starts it, so the route waits a few seconds for that
record before answering, and says in the job's log when it has none.
A launch holds one lock per engine workflow name across its Register
and Start: the engine starts by name and cannot pin the definition
(dagnats' start request has no version), so without it a second
launch's Register could run its file under the first's record.
The value is never written by the forge anywhere but the hash
([Storage](#storage) says what sync needs instead) and never shown on a
page.

### While the run lives, and after

A run token comes with a **login name of its own**, `run-<id>`, where
`<id>` is the row's id. The prefix `run-` is reserved: `AddAccount`,
`InviteAccount`, `agent.new` and `AddTeam` (teams share the namespace)
refuse a name that starts with it, so the login can never collide with
an account or a team, and the resolver can tell a run from a person by
the name alone. The migration that adds the reservation lists any
existing `run-*` account or team in the log each time the database is
opened; it refuses nothing, and each must be renamed before run tokens
ship. zeitforge has none. The job receives both as
`QUARRY_USER` and `QUARRY_TOKEN`.

At the API door the job sends HTTP Basic `run-<id>:<token>`; the
resolver sees the prefix, finds the row by id, compares the hash, and
puts `run-<id>` on the context as the user with `run(T)` as its caps.

At the xfer front the job runs the client it already has. zit 0.3.36's
`zit uv push URL FILE --as NAME` signs a fossil login card from
`--user` and `--password` (or `ZIT_PASSWORD`) and the server's project
code, exactly as it does for a person, so the release script is:

```
ZIT_PASSWORD="$QUARRY_TOKEN" zit uv push "$ORIGIN/r/quarry" \
    quarry-linux-amd64.tar.gz --as quarry-linux-amd64.tar.gz \
    --user "$QUARRY_USER"
```

The card arrives as `login run-<id> NONCE SIGNATURE`. The front sees the
reserved prefix, skips `oltp.Secret` (which answers for accounts), reads
the run row by id, and verifies the signature against the §6.3 secret
the row holds for this repository ([Storage](#storage)), then gates on
`run(T)` rather than on `EffectiveCaps`. **No change to zit is
required**: the protocol is fossil's, the card is the one zit already
builds, and the only novelty is which table the server reads the
secret from. A client that is told the login is `run-<id>` and the
password is the token needs to know nothing else.

The DAG records what the client's `U` card says; unversioned content
has no card and no author in the store. The forge's own records say
"release workflow, run N, started by dmestas's tag" wherever the run
acted, which is the line history shows.

The token ends when the run does: success, failure, timeout or cancel
all revoke it in the transaction that closes the run (and the poller
revokes as soon as the engine reports the run over, before the verdict;
a cancel revokes when it is asked; a sweep at startup and every six
hours revokes the tokens of runs no longer going), so a cancelled job
cannot keep pushing from a shell it left behind. Revoking moves
`sync_secret` to `ended_secret`, which signs nothing and is cleared a
day later, and keeps the row: a leftover shell's login card that
verifies against it is told "workflow run N is over", where anyone else
gets the ordinary refusal (the API door checks the token's hash the
same way before saying so). The sweep also revokes tokens past their
expiry that nobody revoked. The run page shows
one line, each job's token's letters and whether it is live, revoked
or expired (op `ci.run.tokens`), and
the audit `via` column carries the run token's id, which resolves to
the run for as long as the row lives (the yearly sweep, under
[Lifecycle](#lifecycle)).

### What this replaces

`zeit-release` is retired at the cutover rung: its grant is revoked, its
account deleted, and quarry's `ci/release.yml` gains `permissions: {uv:
write}` with `environment: release`, where the `release` environment
has `allowed` `y` and requires its starter to hold `a`. The
account's history, if any, stays under its name. No service account is
created for any workflow after this RFC; a job that seems to need one is
asking for a `permissions:` word this table lacks, and the fix is the
word.

## Lifecycle
[lifecycle]: #lifecycle

**Expiry is required.** The form defaults to 90 days and allows up to a
year; "never" is not offered. A token past its expiry fails every door
with the same refusal as a revoked one, and the row stays, marked, for
the list and the audit. The operator's page shows tokens expiring within
seven days first.

**Rotation is mint-then-revoke**, which is possible precisely because
minting no longer replaces. A coding agent's rotation is: mint the new
token, put it in the keychain the sessions read, revoke the old. The
list shows `last_used`, so the old token's silence confirms the
replacement took before it is revoked. A run token is never rotated;
the next run mints its own.

**Revoke all.** `agent.token.revoke_all` on the operator's page: every
token of every agent of mine, and so every session of theirs, in one
transaction. It is the control a person reaches for when a laptop goes
missing, and it is one button because there is no safe answer to "which
of my twelve tokens was on that machine".

**Operator disabled or deleted.** An agent never holds more than its
operator, and a disabled operator holds nothing, so `SetDisabled` on a
person disables their agents in the same transaction: sessions gone,
tokens refused, the rows kept. `DeleteAccount` today deletes the
`repo_caps` rows and the account row and nothing else; it ends no
sessions, because a deleted account's cookie finds no row. Tokens are
rows of their own, so the cascade is spelled out: deleting an account
ends its sessions, revokes its tokens, and, for a person, suspends
their agents, each marked with the operator's name and the date, all
in one transaction. A site admin may reassign a suspended agent to a
new operator within thirty days (`account.agent` with the new name,
which lifts the suspension), after which the agents are deleted as the
person was. An agent is never
reassigned to itself, and `SetOperator` gains that refusal: today's
schema allows `operator = name`, and the test rig uses it, but under
this RFC it names nobody who can be asked.

**Expiry sweep.** A daily sweep deletes revoked and expired token rows
older than a year and spent sign-in links older than a day. Nothing
else is garbage-collected; a person's deleted agents go when the person
would.

## Audit and history
[audit]: #audit-and-history

The DAG shows the account: a check-in by `dmestas-release` is by
`dmestas-release` in every clone forever, which is what decision 1 means
by "history shows the agent". The agent badge (`agentBadge`, built)
names the operator in its title wherever the name is drawn. A
write-level mark in the artifact format is still not proposed; agent.go
says why, and this RFC makes the account-level mark worth more rather
than replacing it.

The forge's own records show the token. The resolver puts the token's
id on the context beside the user (`ops.WithToken`), and four places
read it:

- the session row (`token_id`), so the device list says which token
  opened a session;
- the 24-hour event trail and the repository settings log gain a `via`
  column holding the token id, so "who changed `mirrorable`" can answer
  "dmestas-release, via token `release-2026q4`";
- the push receipt: the xfer front already knows which candidate
  matched, and the sync log line names it;
- `last_used` on the token row, bounded to one write per five minutes
  per token, as `sessionSeenEvery` bounds `last_seen`.

A token's name is the operator's label and may be reused after
revocation; the id never is. Pages show the name and keep the id in a
title attribute, as the badge does with the operator.

## Storage
[storage]: #storage

A token with **api** or **web** scope and no **sync** scope is stored as
its SHA-256, like a session cookie and an invite: a stolen database
holds nothing a request can present. A token with **sync** scope is
stored **sealed** (`secret.go`), because the sync protocol's login card
proves the client knows the §6.3 secret, and the server can only check
that by deriving it from the token it holds. synctoken.go states the
trade and it holds unchanged here: a reversible secret at rest, scoped
to what the token is scoped to, in exchange for per-repository secrets
derived on demand so a repository created after minting needs no
re-issue. An operator who wants no sealed secret for an agent gives it
no sync scope.

A run token is hashed. When its permissions include `i` or `y` the
front still needs a §6.3 secret to verify the login card, and a run
token names exactly one repository, so the forge stores that one
derived secret (`SHA1(project-code/run-<id>/token)`) in `sync_secret`
beside the hash instead of sealing the token. That value is a usable
credential for one repository for the life of one run and nothing else;
revoking the token moves it aside, where it signs nothing, for a day,
so the run's end can be told to the shell still holding it, and keeps
the row, so the audit can still resolve the id while nothing usable
remains. It is the
smallest thing the protocol allows the server to keep, and it keeps it
for minutes.

The value is shown once; `agent.token.list` and the page answer names,
scopes, dates and the id, never the verifier. Backups of OLTP carry the
sealed sync tokens as they carry the sealed bucket credentials today
(RFC 0016), under the same key.

## Rate limits
[limits]: #rate-limits

Three bounds. The first two are forge settings with defaults, in memory
like the intake limiter; the third is the limiter that already exists:

- **Tokens per account: 25.** This bounds the xfer loop and is the
  signal that a machine is minting per session instead of per role.
- **Sign-in links: 30 per token per hour.** A session lasts a day; a
  machine minting a link a minute is looping.
- **Failed token checks feed `loginLimiter`** (internal/web/web.go),
  which already slows password guessing per account and per address
  with a doubling delay after five misses and a reset on success. A
  refused token is a miss against the account it names (or the run
  login), at every door; a refused token costs a hash compare and, for
  sync, a secret derivation per candidate, and a flood of either is the
  thing the limiter exists for. No second limiter is built.

API requests by a valid token are not limited by this RFC; an agent
that is too chatty is an agent whose token its operator can revoke, and
that is the control that fits the identity model.

## What the forge shows
[public]: #what-the-forge-shows

An agent's profile page (the same page a person gets) says **agent**,
names the operator with a link, shows the description and the created
date, and lists its public activity as it does for anyone. It shows no
tokens, no scopes and no repositories list beyond what activity already
reveals. The People page of a repository and the team page draw the
badge next to the name, as built; a forge agent's badge reads "forge
agent" with the answerable admin in its title. The operator's own
settings page is the only place a person's agent's tokens appear, and
`agent.list` is its op; a forge agent's appear on `/fleet`, to site
admins.

## Team-owned agents
[teams]: #team-owned-agents

RFC 0018 put teams and accounts in one namespace, so the `operator`
column can name a team today without a schema change. The model
extends by substitution:

- **operator = a team.** The team's owners (membership rows with
  `owner = 1`) are who may mint, grant, revoke and delete, the same
  acts an operator does for their own agent. `agent.new` with
  `operator: <team>` is a team-owner op.
- **ceiling = the team's grant.** `effective(O, R)` for a team operator
  is the membership floor plus the team's row on R, which is the set a
  member reaches through the team and nothing more. An agent of a team
  therefore reaches exactly the team's repositories at exactly the
  team's strength, with no person's grants mixed in.
- **lifecycle.** Dissolving a team suspends its agents as deleting a
  person does, with the same thirty days for a site admin to reassign.

Deferred: the team-owner gate on the agent ops, the team page's
"Agents" section, and the team form of the ceiling. Built now: nothing
that would have to change for it, which is the point of writing it
down. Whether an agent may be a team *member* in its own right (as
opposed to being owned by a team) is not proposed; the operator route
already carries a team's floor to a person's agent.

## Migration
[migration]: #migration

Three kinds of existing row.

**Accounts already marked as agents.** On zeitforge: `zeitgeist`,
operator `dmestas`, site admin, which becomes a **forge agent**
(decision 10): a site admin runs `account.agent` with `operator: forge`
and `answerable: dmestas`, it keeps its flag, its grants, its deeds and
its history, and its password is retired once its tokens are in place,
on the same schedule as every other marked account below; and
`zeit-release`, which is a job's account and is retired at cutover as
[Run tokens](#runs) says. A remaining agent keeps its name, its grants
and its history. At
the cutover rung its password is cleared (`pwhash = ''`), which ends
every password sign-in and every Basic call that used it; before that
rung the operator's page warns on each marked agent that still holds a
password, and the operator mints the API and web tokens the sessions
will switch to. An agent marked with `operator = <itself>` is given a
real operator by a site admin before cutover, or cutover refuses it by
name. Between the token rung and the cutover an agent accepts both,
which is the window in which running sessions move.

**Sync tokens.** Every `sync_token` row becomes a `token` row named
`push`, scope **sync**, every repository, no ceiling, sealed, created as
it was, expiring one year from the migration so that a credential
minted before expiry existed does not become the one that never
expires. The `sync_token` table is kept, not dropped: OLTP migrations
are additive, so an older build rolled back onto the database still
finds the push credentials it knows. Each copied row is marked as
migrated, and the two are kept in step on every open, in one
transaction: a copy whose `sync_token` row changed or vanished (an older
build minted again after a rollback) is retired, a row not yet copied is
copied once, and revoking a copy deletes its `sync_token` row so a
rollback cannot revive it. The table can be dropped once no release
older than rung 2 can be rolled back to. Hand-issued `sync_user` rows
(`oltp.Secret` answers them first, and the comment there says somebody
is relying on one) are listed by the migration and left in place: they
keep winning at the front as today, they appear on the account's Push
credential list as "legacy, per repository" with a revoke, and no new
one can be issued. The three `me.sync.token*` ops
stay as documented doors for a person: mint now **adds** a sync token
named `push <date>` rather than retiring the previous, which is the
hazard this RFC exists to end, so the one-line change to docs/api.md is
the Amends line above; `revoke` revokes every sync-scoped token of the
caller; `state` reports `held` when any is live. The Push credential
section on `/me` becomes a list with a revoke per row, the same
component the agents section uses. A person's tokens are the same table
and the same resolver, so a person may hold API tokens too; a form for
that is not in this RFC's build order and nothing refuses it.

**CI reporter tokens** (`ci_reporter_token`, the `ci.token.*` ops) are
not touched. They are a repository's credential for an external CI
system to report checks (RFC 0014's second phase), not an account's, and
they gate one op. Folding them into agent tokens with a `report` scope
would be tidy and is not proposed; the day external CI needs to do more
than report, the answer is an agent.

## Interactions
[interactions]: #interactions

**RFC 0019 environments.** A CI job's identity on the forge is a run
token ([Run tokens](#runs)), minted under the environment's branch
policy and delivered beside the environment's secrets by the bridge.
The environment gains two fields, `allowed`, the most a job in it may
be handed, and an optional required starter letter; the file gains
`permissions:`. Nothing about secret delivery
changes, and a job that declares no write permissions is served exactly
as today. An agent token placed in an environment secret by hand still
works, since a secret is a secret, and is the wrong tool for a job: it
outlives the run.

**Ticket #323, buckets by user and team.** A bucket (RFC 0016, RFC
0020) hangs off a repository's deed, and #323 extends the binding to a
person's and a team's buckets. An agent never holds a deed: `repo.claim`
already hands a deed to a repository admin and never to a site admin,
and this RFC adds that `repo.new` by an agent writes the deed to its
operator, so the agent acts and the operator owns, as with every other
thing an agent does. Since an agent owns no repository it never binds or
applies a bucket, and #323 needs no agent case. Confirmed, with the one
rule added here that makes it true.

**RFC 0017 write tiers.** Unchanged. An agent writes at whatever tier
its letters allow, and the ceiling is a way to hold a token to the local
tier (a ceiling of `r` files local tickets and nothing permanent).

**RFC 0001 §11.** Accounts, tokens and sessions are forge OLTP state and
were never under the disposability test; nothing here reaches the
indexes or the DAG.

## What is not proposed
[not-proposed]: #what-is-not-proposed

- **A write-level agent mark in the artifact format.** agent.go's
  reasoning stands.
- **OAuth clients, refresh tokens, or a third-party app model.** A token
  is a bearer secret held by the operator's own machine; there is no
  party between.
- **Agents owning repositories or holding `s`.**
- **Service accounts for workflows.** A job gets a run token; an
  account for a job is the shape this RFC retires.
- **A starter-letters intersection on run tokens.** Considered and
  declined; the reasoning is under [Minting](#runs).
- **Agents as team members.** The operator route carries the floor.
- **Scopes finer than letters.** The ceiling is a cap string because
  the letters are the model (RFC 0004) and a second vocabulary beside
  them is what RFC 0018 declined for teams.

## Build order
[build]: #build-order

| Rung | Work | Waits on |
|---|---|---|
| 1 | This RFC; decisions recorded | done |
| 2 | The `token` table and the resolver: the `api.Verify` implementation, the web Basic path and `syncIdentityCtx` accept a token with the right scope; `oltp.Secret` loops over sync-scoped tokens and answers `y` without `i` for uv-only pushes; `verifyLogin` compares in constant time; token misses feed `loginLimiter`; the `run-` prefix reserved in `AddAccount`, `InviteAccount` and `AddTeam` with the existing-names check; `sync_token` rows migrated and `sync_user` rows listed; `me.sync.token*` re-pointed; the `/me` Push credential section becomes a list; the limits in [What a token may not do](#what-a-token-may-not-do) | built |
| 3 | Agents: `agent.new/list/delete`, `agent.token.new/list/revoke/revoke_all`, `agent.grant` (any repository the operator reaches, clipped to their letters); the operator intersection in `EffectiveCaps`; `agents_per_person`; the "Your agents" section on `/me`; the disable and delete cascades (sessions, tokens, agents); `account.agent` clears the password on marking and mints an invite on clearing; `account.invite` and `account.reset` refuse agents; `SetOperator` refuses self; `SetOwner` and the three deed acts refuse an agent, unclaimed repositories included; the two `a` guards; forge agents (`operator: forge` reserved, `account.answerable`, the `s`-op creation path, the fleet section, the badge, the deed and guard rules of §Forge agents); the Publisher preset (a constant in `roles.go`, `Infer`, the roles editor, the role-change sentences, `roles_test.go`) | rung 2 |
| 4 | Web sign-in: `me.signin-link`, the `signin_link` table, the redemption with `Referrer-Policy: no-referrer` and an immediate redirect, 24-hour token-bound sessions with `token_id`; the recipe in `/llms.txt` | rung 2 |
| 5 | Audit and limits: `ops.WithToken`, `via` on the event trail and settings log, the push receipt, `last_used`; the token cap and the sign-in link bound | rungs 3, 4 |
| 6 | Run tokens: `permissions:` read, validated and stripped by quarry before dagnats compiles (no dagnats change), `allowed` and the required starter letter on environments and the default policy, minting at run start under the branch policy and the starter requirement, the `run-<id>` login at the API door and the xfer front, delivery through the bridge, revoke on close and cancel, the run page line | built |
| 7 | Cutover: zeitgeist re-marked as a forge agent answerable to dmestas, marked agents lose their passwords, `/llms.txt` and `/start` rewritten (an agent is its own account; a job is a run), `ci/release.yml` declares `uv: write`, `zeit-release` retired | rungs 3, 4, 5, 6 |
| 8 | Team-owned agents, per §Team-owned agents | rung 7, and a decision to start |

Rungs 3, 4 and 6 are independent and can go in parallel. Rung 7 is the
only one with a date: it is announced on the forge a week ahead, because
it ends credentials people's sessions are using.

## Testing
[testing]: #testing

- **The resolver.** A table of (door, scope, repository, state) against
  accept/refuse: an api-only token on the xfer front, a sync-only token
  on `/api/v1`, a token naming `quarry` used on `pikchr-studio`, an
  expired one, a revoked one, each refused with the same status as a
  wrong password and in the same time; the right one accepted and
  `last_used` written once in five minutes. Two sync tokens on one
  account: a push signed with either lands and the receipt names the
  one it used; revoking one leaves the other pushing (the concurrent-
  sessions case, as a test).
- **The ceiling.** For every combination of (agent grant, operator
  effective set, token ceiling) over the role presets: the resolved
  string is the intersection plus the nobody category; the operator
  losing a letter removes it from the agent on the next call with no
  write; a site-admin operator yields no `s`; a private repository the
  operator cannot see does not exist for the agent.
- **Granting.** `agent.grant` on a repository the operator does not
  administer stores the string clipped to the operator's letters there
  and refuses on one the operator cannot reach; `account.grant` by a
  repository admin on an agent still resolves under the operator's
  letters; the People page lists the agent with its operator.
- **Presets.** `roles_test.go` asserts each of the five sets exactly:
  Publisher is Reader's letters plus `y`, the four existing sets are
  unchanged, and there is no Steward preset. An agent holding Admin and
  `y` is refused by `repo.delete`, `repo.remove`, `repo.claim` and
  `SetOwner`. `Infer` reads `ay` as Admin and can publish, and Reader
  plus `y` as Publisher. The role-change sentences for Publisher are
  rendered and read right in a browser pass.
- **The deed.** `repo.delete`, `repo.remove` and `repo.claim` refuse an
  agent holding `a`, on a held and on an unclaimed repository alike;
  `SetOwner` refuses an agent; `repo.new` by an agent
  writes the operator as owner, and `OwnedRepoCount` of the agent stays
  zero. An agent with `a` cannot revoke or lower its operator or the
  deed holder, and cannot grant a third account a letter it does not
  itself resolve to; a person with `a` still can, so the guards are
  keyed on the caller being an agent and nothing else.
- **Web sign-in.** A link opens one session and a second `GET` of it is
  the spent page; the session carries the token id and its ceiling; it
  expires at 24 hours; revoking the token ends it in the same
  transaction; a link past two minutes is refused; a sign-in token
  presented at `/invite/<token>` or the reset path is unknown there,
  and `account.invite` and `account.reset` on an agent are refused by
  name; the redemption response carries `Referrer-Policy: no-referrer`
  and a 303. A browser pass with `agent-browser` under its own session
  name, per the standing rule that UI is verified by looking.
- **The sync lane.** A clone of a private repository with a
  sync-scoped token in the Basic password slot succeeds on the first
  round (`syncIdentityCtx`), with an api-only token it is 404 as for
  nobody, and with `run-<id>` and its token it succeeds for the run's
  repository only. `verifyLogin` is pinned to a constant-time compare.
- **Forge agents.** `agent.new` with `operator: forge` is refused to
  anyone but a site admin and requires `answerable`; `forge` is refused
  as an account and a team name; a forge agent with the flag passes
  `account.add` and the operator paths and is refused on `account.agent`
  for another forge agent and on any `s` flag change; `SetOwner`
  accepts it and `repo.new` deeds to it; any site admin revokes its
  tokens and reassigns `answerable`; disabling the answerable admin
  leaves the agent working and the fleet page says "answerable:
  nobody"; its effective set has no operator term; the badge reads
  "forge agent" in a browser pass. Migration: zeitgeist re-marked
  keeps `s`, its grants and its deeds, and after cutover its password
  is refused and its tokens are not.
- **Marking.** `account.agent` on an account with a password clears it
  and ends its sessions in one transaction; clearing the mark on a
  passwordless account returns an invite link, and nothing can sign in
  as the row in between.
- **Lifecycle.** Disabling and deleting an operator suspends their
  agents; reassignment lifts it; day thirty-one deletes; `revoke_all`
  leaves no live token or session across two agents.
- **Migration.** A store with `sync_token` rows and a marked agent
  before the migration pushes and signs in after it exactly as before,
  until cutover; after cutover the password is refused and a token is
  not.
- **Run tokens.** A job declaring `uv: write` on a protected branch
  gets a token whose letters are `y` plus the public set; the same job
  on an unprotected branch or on a landing's merge gets none, and the
  run says why; a tag by a person without `a` in an environment that
  requires `a` runs, mints nothing, and the log names the letter; a
  tagger holding `a` and not `y` gets `y` (no starter intersection); a
  `schedule` run mints in an environment without a starter requirement
  and is refused in one with it; a job declaring nothing on a public
  repository mints nothing; `allowed` narrower than the declaration
  wins; an unknown `permissions:` word is a named
  error with a position. `zit uv push` with the token as the password
  lands during the run and is refused one second after the run closes,
  and after a cancel; `sync_secret` is empty and the row still resolves
  the id. A Publisher's token and a `y`-only run token push a uv card
  over xfer and are refused whole when a check-in card is added. The
  runner's log shows the mask and never the value.
- **Audit.** The settings log and the event trail carry the token id for
  a change made by a token and `''` for one made by a person's session;
  a run token's id resolves to "workflow, run N, started by X" on the
  page.
- **Limits.** The twenty-sixth token is refused; the thirty-first link
  in an hour is refused; five token misses against one account buy the
  same doubling delay `loginLimiter` gives five password misses, at
  every door, and a success clears it.
