Skip to content
quarry Artifact [619f0e1b7e]
Login

Artifact [619f0e1b7e]

Artifact 619f0e1b7e25d4d41c82b9a6b193c578e2422a85788add515333c45353cdd6d0:


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

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

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, with the operator's approval as the gate.
  6. Teams: planned, deferrable. Team-owned agents (RFC 0018) are designed in Team-owned agents 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.
  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.
  9. Two new role presets, for people and agents alike. Beside RFC 0008's Reader, Developer, Maintainer and Admin: Steward, a plus y plus x (everything short of the deed acts), and Publisher, Reader's letters plus y (reads, and publishes downloads and release files; no code, tickets or settings). Admin stays as it is: managing settings does not make you a publisher, which is fossil's rule. See 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.

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

The model

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, 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

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:

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). 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.

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

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.

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

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

Presets

RFC 0008's four presets were written for people on a forge with no machines. The two shapes this RFC makes common, an agent that runs a repository for its operator and a bot that uploads files, fall between them: Admin stops short of y, so a coding agent given Admin cannot publish a build, and nothing below Admin publishes at all. Two presets are 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
Steward a y x everything short of the deed acts: Admin plus unversioned files and private branches
Publisher Reader's letters plus y reads, and publishes downloads and release files; no code, tickets or settings

Steward never includes s; the deed stays the owner's, and What an agent may hold says what a Steward that is an agent cannot do even so. The name follows RFC 0008's pending steward amendment (hosting is not owning): a Steward holds everything about a repository except the fact of owning it.

Which preset for which case, as the reasoning:

scenario preset
your own coding agent on your repositories Steward
a trusted co-maintainer who must not delete or take the repository Steward
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 six presets instead of four, the role-change sentences gain the two names, and RoleOf's nearest-preset reading (Infer) takes the two new sets into account, so an account holding ay reads as Steward minus x rather than as Admin plus two letters.

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.

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). 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

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:

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

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:

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

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 (built as a rule, not reached yet: dagnats starts schedule runs, quarry records none, and the token route mints only for a run quarry recorded. A schedule run cannot yet be tied to reviewed code, because the engine runs whatever definition was last registered under the workflow's name, and quarry registers on every launch, unprotected branches and merges included; adopting one needs schedule definitions registered from the default branch only): it 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 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), 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).

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

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

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:

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

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

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

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

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

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:

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

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

RFC 0019 environments. A CI job's identity on the forge is a run token (Run tokens), 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

  1. 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

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 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 Steward and Publisher presets (constants 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