Artifact ecf2240fc7668403814e97c0fb67cd39e443c823532a99a1722385852a5db7c9:
- File docs/rfcs/0021-agent-accounts.md — part of check-in [6884c863f2] at 2026-10-02 20:39:44 on branch rfc21-run-tokens — RFC 0021 rung 6: run tokens, built; the RFC corrected to the built shape (user: zeitgeist, size: 73527) [annotate] [blame] [check-ins using]
- 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). 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
afloor 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.tokenandme.sync.token.revoke: minting no longer retires the previous credential, see Migration; RFC 0019 §The file (apermissions:key, see Run tokens) and §Environments and secrets (the bridge delivers a run token beside the secrets); RFC 0008's presets (two more, Steward and Publisher, see Presets); the agent guide at/llms.txt, which today says "there is no separate bot identity" - Precedent:
internal/oltp/agent.go(theoperatorcolumn: 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
operatorcolumn 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
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.
- 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.
- 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.
- 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.
- Creation: self-serve. Any person creates agents for themselves from their own settings. Site admins may cap the number per person or disable it.
- 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.
- Teams: planned, deferrable. Team-owned agents (RFC 0018) are designed in Team-owned agents and built later.
- 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. - 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. - Two new role presets, for people and agents alike. Beside RFC
0008's Reader, Developer, Maintainer and Admin: Steward,
aplusyplusx(everything short of the deed acts), and Publisher, Reader's letters plusy(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. - 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
swhen 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'ss. See 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).
- A person's agent is never a site admin, whoever its operator is (a forge agent may be, by decision 10).
repo.newby an agent puts the deed on its operator.- People get the same multi-token list; minting adds rather than replaces (Migration).
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:
/api/v1: HTTP Basic with the agent's name and the token as the password. Theapi.Verifyimplementation (cmd/quarry/main.go) gains a branch beforeAuthenticate: 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 againstrepos. 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 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 arun-<id>login with its run token. - The xfer front proper: the login card carries
USER NONCE SIGNATUREand the client signs with SHA-1 over the nonce and the §6.3 shared secret; the card never carries the token.oltp.Secrettoday answers a hand-issuedsync_userrow first, then unseals the one sync token and derives the repository's secret. It keeps thesync_userorder 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;verifyLoginis tried against each, withsubtle.ConstantTimeComparein 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), 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).
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:
account.grantandaccount.revokeby 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
athrough adeveloperceiling writesdeveloperat most; an agent whose operator holdsabut nots-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
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:
- Made by a site admin only.
agent.newwithoperator: forgeis ansop. The nameforgeis reserved likerun-: no account or team may take it, andSetOperatoraccepts it only from a site admin. - The operator is the forge; a site admin is answerable.
account.answerablenames 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, andaccount.disable,account.deleteor 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
tokenrows, 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)), withsat 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.
SetOwneraccepts a forge agent;repo.newby one deeds the repository to the agent itself;repo.delete,repo.removeandrepo.claimtreat it as the account it is, deed-gated like a person and, when it holdss, 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 withrepo.claimas 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_persondoes 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
aguards 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 holdingsmay do what a site admin may,account.addincluded, except make or alter another forge agent or change any account'ssflag: those stay with a person, so that a machine cannot multiply machines or grant itself a successor.
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 newtoken_idcolumn onsessionbinds 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 existingagentcolumn 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
Refererheader. Single use and the two-minute TTL make a later copy worthless; the redemption response carriesReferrer-Policy: no-referrerand 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
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 |
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; the settings
page does not show them yet), 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 check-in's author for push (the
name the forge can tie to a check-in when the trigger reads it; the
pusher of the round is not recorded),
the tagger for tag, the landing's author for landing, the clicker
for workflow_dispatch. 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; quarry starts no
schedule run itself today, schedule being dagnats' own trigger, so no
path reaches it yet): 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 (360 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, handed a token, fails the task unrun, as it
does for secrets; one asking a forge without the route gets nothing.
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 clears
sync_secret and keeps the row, as for any token: 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:
- 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
viacolumn holding the token id, so "who changedmirrorable" can answer "dmestas-release, via tokenrelease-2026q4"; - the push receipt: the xfer front already knows which candidate matched, and the sync log line names it;
last_usedon the token row, bounded to one write per five minutes per token, assessionSeenEveryboundslast_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
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 clears the column 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:
- 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
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:
- 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.newwithoperator: <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
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
- 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.claimalready hands a deed to a repository admin and never to a site admin, and this RFC adds thatrepo.newby 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
- 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.
- 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
| 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
- 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 namingquarryused onpikchr-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 andlast_usedwritten 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.granton 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.grantby 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.goasserts each of the six sets exactly: Steward isayxand nos, Publisher is Reader's letters plusy, the four existing sets are unchanged. A Steward, person or agent, is refused byrepo.delete,repo.removeandrepo.claim; an agent Steward is refused bySetOwneras well.Inferreadsayas Steward minusxand Reader plusyas Publisher. The role-change sentences for the two names are rendered and read right in a browser pass. - The deed.
repo.delete,repo.removeandrepo.claimrefuse an agent holdinga, on a held and on an unclaimed repository alike;SetOwnerrefuses an agent;repo.newby an agent writes the operator as owner, andOwnedRepoCountof the agent stays zero. An agent withacannot 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 withastill 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
GETof 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, andaccount.inviteandaccount.reseton an agent are refused by name; the redemption response carriesReferrer-Policy: no-referrerand a 303. A browser pass withagent-browserunder 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 withrun-<id>and its token it succeeds for the run's repository only.verifyLoginis pinned to a constant-time compare. - Forge agents.
agent.newwithoperator: forgeis refused to anyone but a site admin and requiresanswerable;forgeis refused as an account and a team name; a forge agent with the flag passesaccount.addand the operator paths and is refused onaccount.agentfor another forge agent and on anysflag change;SetOwneraccepts it andrepo.newdeeds to it; any site admin revokes its tokens and reassignsanswerable; 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 keepss, its grants and its deeds, and after cutover its password is refused and its tokens are not. - Marking.
account.agenton 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_allleaves no live token or session across two agents. - Migration. A store with
sync_tokenrows 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: writeon a protected branch gets a token whose letters areyplus 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 withoutain an environment that requiresaruns, mints nothing, and the log names the letter; a tagger holdingaand notygetsy(no starter intersection); aschedulerun 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;allowednarrower than the declaration wins; an unknownpermissions:word is a named error with a position.zit uv pushwith the token as the password lands during the run and is refused one second after the run closes, and after a cancel;sync_secretis empty and the row still resolves the id. A Publisher's token and ay-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
loginLimitergives five password misses, at every door, and a success clears it.