# The write pipeline
What a write passes through before it reaches the log, in order, and what each
stage costs, checks and answers.
The order is not arbitrary. It runs **broadest and cheapest first**, so a write
that cannot possibly succeed is refused before anything expensive happens, and
every stage's refusal is **distinguishable on the wire** — an agent retries
differently for each, and one that cannot tell them apart retries the wrong
thing forever.
```mermaid
flowchart TD
R[["write request"]] --> E
E{{"1 · e-stop
Estop::check_use / check_issue"}}
E -->|"Revoke, or Pause on issuance"| EH["503 Halted"]
E -->|clear, or Pause on a record write| L
L{{"2 · lifecycle
ServerState policy"}}
L -->|"not accepting writes"| LH["503 ServerNotReady
Retry-After, blocking gates named"]
L -->|accepting| F
F{{"3 · per-repo freeze
Provisioner::require_writable"}}
F -->|"AccountState refuses writes"| FH["403 account frozen
reason: admin | lifetime | policy"]
F -->|writable| P
P{{"4 · parsing
record + diff"}}
P -->|malformed| PH["400 InvalidRecord"]
P -->|"parsed, diff computed"| Q
Q{{"5 · per-repo queue
admission order"}}
Q -->|full| QH["429 / 503 backpressure"]
Q -->|queued| T
T{{"6 · policy tree
applicability index"}}
T -->|"no policy matches"| W
T -->|"candidates, in order"| V
V{{"7 · evaluation
Evaluator::evaluate"}}
V -->|"Reject"| VH["403 policy refused
policy-authored reason"]
V -->|"Freeze"| VF["403 account frozen
drains this repo's queue"]
V -->|"unavailable / over its deadline"| VU["403 refused
fail closed, scoped to this policy"]
V -->|Allow| W
W(["8 · commit
store lock held here only"]) --> S["sequence assigned
policy version + now recorded"]
```
## What each stage checks
### Stage 1 · E-stop — `didbot_pds::Estop`
The cheapest check there is: an atomic latch read, no credential, no lookup.
It runs first because it is both cheapest and most urgent, and because
`Halted` is the wire answer callers already switch on.
**It is not one gate.** The mode and the operation class decide together:
| | `Mode::Pause` | `Mode::Revoke` |
| --- | --- | --- |
| record write to an existing repo (`check_use`) | proceeds | halted |
| provisioning a new account (`check_issue`) | halted | halted |
`Pause` blocks issuance and leaves outstanding work alone by definition, which
is why a lapsed operator claim throws `Pause` and not `Revoke`: an operator
going unreachable must not stop the agents already here from writing.
E-stop and the lifecycle are **independent facts**. A server can be fully
claimed and halted, and neither is derivable from the other.
### Stage 2 · Lifecycle — `didbot_pds::ServerState`
Whether this *deployment* accepts writes at all. Public: no credential is
spent to learn it, because "this server is not accepting writes right now" is
not a secret and paying for a credential lookup to reach it is work for
nothing.
Answers `503 ServerNotReady` with `Retry-After` and the blocking gates named,
never a `404` — "this method does not exist" and "this method is not ready"
are facts a relay reacts to completely differently.
### Stage 3 · Per-repo freeze — `Provisioner::require_writable`
`AccountState`'s policy for the named repository. Cheap, and **needs no
authentication** for a reason worth stating: the repository identifier is
caller-supplied, but the only thing a caller achieves by supplying one is
getting itself refused. **A check that can only deny is safe on
unauthenticated input.** Trusting caller-supplied input is dangerous when it
grants and harmless when it only denies.
A freeze carries *why* — an administrator, a lifetime rule, or a policy — and
that reason is distinct from a policy refusal. "Account frozen" says nothing
you write will work until an operator acts; "policy refused" says fix this
write. An agent that cannot tell them apart retries the wrong one.
### Stage 4 · Parsing
Necessarily before policy: a diff cannot be computed without parsing, and
policies are evaluated against the diff.
The diff carries **before and after values**, not only changed paths, with
absence treated as a value. That is what lets a rule resolve create, update
and delete uniformly — `before.is_some() && after != before` — with no action
special-case anywhere.
### Stage 5 · Per-repo queue
The linearization unit is the **repository, not the server**. Order within one
repository is load-bearing: its commits chain, each naming its predecessor.
Order between two repositories is observable by nobody. So each repository has
its own queue, different repositories evaluate concurrently, and head-of-line
blocking is bounded to the agent that caused it.
The queue is bounded. A full queue is backpressure a caller can act on, not a
memory leak.
### Stage 6 · Policy tree — applicability index
The tree **indexes**; what comes out of it is an ordered list. Candidates are
selected on collection, path, action and subject kind. A write matching
nothing pays only for the walk it already owed, because the diff it needed for
indexing is the diff it needed anyway.
Selecting on kind at the tree is what keeps a policy scoped to agents from
ever seeing a host's write.
### Stage 7 · Evaluation
Policies **only deny**. Any deny from any source is sufficient, so adding a
policy can never widen what is permitted. That monotonicity is what makes the
rest coherent: order is presentational rather than semantic, precedence
between sources needs no rule, and **failing closed is strictly conservative**
— a refusal on an unavailable evaluator is guaranteed to be at least as
restrictive as a complete evaluation would have been.
Failing closed is **scoped by the tree**: if the evaluator serving one policy
is down, only writes that policy would have judged are refused. Everything
else is untouched.
`Freeze` is a transition on the repository's queue rather than a message sent
beside it. The write that trips it drains that repository's pending writes
with a rejection naming the freeze — which is also why a metapolicy that
freezes agents for tripping policies never observes the denials its own freeze
caused: those writes are drained without evaluation. That safety property
falls out of the per-repo queue, and parallelising the drain would silently
reintroduce the loop.
### Stage 8 · Commit
The **only** stage that holds the store lock. Evaluation happens outside it,
so a slow policy never holds the single writer, and a denied write never needs
un-committing — which the write-ahead log cannot do cheaply.
The sequence number is assigned at admission, not arrival. The policy version
and the `now` that judged the write are recorded with it, because decisions
are not reproducible — an evaluator may have a model in the loop, and an agent
may delete data an evaluation read.
## A batch is one commit, judged operation by operation
`com.atproto.repo.applyWrites` carries several writes and produces exactly one
commit. It runs the same eight stages, with three things settled by that "one
commit":
- **One subject per operation.** Each operation has its own collection, action
and diff, so each is its own write subject at stages 6 and 7. Folding a
batch into a single subject would hide every operation but one from the
tree.
- **One turn in the repository's queue, not one per operation.** The whole
batch takes stage 5's line once. Judging operation three after another
write to the same repository slipped in behind operation two would leave the
batch judged against a state it is not committed against.
- **All or nothing.** Stage 8 runs only if every operation was allowed. The
record store already applies a batch atomically, a partially applied batch
would be a commit no caller asked for, and deny-only monotonicity's answer to
"some of this is refused" is to refuse.
The batch's answer is the **most severe** of its operations' outcomes, not the
first: a freeze tripped by one operation outranks a rejection tripped by
another, and reaches the caller as a freeze with the account frozen. Judgment
stops at the first freeze and no later operation is judged or observed, for the
same reason a freeze drains the writes queued behind it without judging them.
A rejection does not stop judgment, so every operation the caller attempted is
still observed — a batch must not be a cheaper way to hide attempts from a
stateful evaluator than the same writes sent one at a time.
## Where authentication sits
Not as a stage of its own, and deliberately.
Verifying a token cryptographically is mechanism, and cheap. Deciding whether
a token **may be used** is a policy question and belongs at stage 7: a token
issued before its application was denied is still perfectly valid and still
names a real account, and only a write-tree rule that can see `client_id`
catches it. That is why `client_id` is on the write subject and not only on
the grant subject.
So nothing before stage 7 spends a credential, and nothing before stage 7
needs to.
## Where each gate actually runs
The order above is the order the routes run these checks in, and two details
of *how far* each one gets ahead of the body are worth stating, because both
are load-bearing and neither is obvious from the diagram.
**Stages 1 and 2 read nothing.** Every `com.atproto.repo.*` write route
consults the e-stop and then the lifecycle as its first two acts — before
authenticating, before deserializing, before resolving a handle. A caller
that sends an unparseable body to a halted server is told `Halted`, not
`InvalidRequest`, because the latch is an atomic read that needs no body and
the former is the fact worth acting on. The same gate covers the `bot.did.*`
routes that destroy an account or widen what it may do: `deleteAgent`,
`unfreezeAgent`, `activateAgent` and `setAgentPinned` are refused under a
`Revoke`. `freezeAgent` and `deactivateAgent` are not, and deliberately —
each can only ever narrow what an account may do, and a stop that stood
between a caller and restraining itself would be worse than the one it
replaced.
**Stage 3 needs the envelope and nothing more.** Which repository is meant is
in the request, so the freeze check cannot precede deserialization outright
the way stages 1 and 2 do. It precedes every judgment of the *record*, which
is what stage 4 is: a frozen account sending a record its lexicon refuses is
told `AccountNotWritable`, never `InvalidRecord`.
**Stages 1 and 3 are read twice.** Both latches are read fresh every time, so
each is checked again immediately before the write — the e-stop just before
the store lock is taken, and the account's writability inside it, against a
re-read of the account rather than the one the write entered with. A write
can wait a long time at stage 5 while a slow judgment runs ahead of it, and
an operator who freezes an account in that window must not be told the
account is frozen while a write already past stage 3 goes on to commit. The
second read is what closes that. It does not make the gates atomic with the
commit — nothing before the store lock can be — but every window it leaves is
inside one store lock rather than around a policy evaluation.
Every path that queues reads it twice, `applyWrites` included, and a batch
that finds the account frozen at the second read is refused whole: a batch is
one commit, so there is no half of it to keep. That refusal is
`AccountNotWritable`, not the freeze a policy verdict raises — an operator's
freeze arriving from outside neither stops judgment of the operations behind
it nor freezes the repository's line, which only stage 7 does.
## Shortcuts
- **Stages 1 and 2 short-circuit before any parsing, and stage 3 before any
parsing of the record.** A halted server, a server not yet accepting
writes, and a frozen account are all decided without judging the request
body.
- **Stage 6 short-circuits on an empty match**, which is the common case.
- **Stage 7 short-circuits the decision channel** — once a `Freeze` is
reached, evaluation stops. The **observation** channel does not
short-circuit: every evaluator watching a matching surface sees every
attempt and its outcome, including attempts an earlier policy already
denied, because a metapolicy counting policy failures must see denials it
did not cause.
- **A freeze drains the rest of that repository's queue** without evaluating
any of it.
## What is recorded
- **An allow** costs a policy version and a hash of the matched set. A row per
admitted write is where the volume is and buys nothing.
- **A denial** records an evaluation id, the policy version, which policies
fired, the verdict, a hash of the payload, and a **policy-authored** reason —
and **no part of the refused payload**. The refused data is by definition
what a policy decided should not exist here; storing it would put it durably
on disk in a log with its own retention, written by the mechanism meant to
prevent it.
- Detail flows to the party that already has it: the rejection returned to the
caller may be as specific as the policy likes, because the caller sent the
data.