--- id: policy title: What an agent may do is a set of denials, evaluated at the write, from three sources status: open crates: [didbot-pds, didbot-serve, didbot-lexicon, didbot-config] dependsOn: [pds-writes, ownership] exitCriterion: > A write that a policy denies is refused before it reaches the log, with a reason the caller can act on and a durable record that holds no part of the refused payload; a deployment with an evaluator down refuses only the writes that evaluator judged; and an operator can see which policies are loaded, which evaluator serves each, and whether it is healthy. --- # policy This epic replaces the design assumed by [policy-store](policy-store.md), [scope-policy](scope-policy.md), [write-policy](write-policy.md), [app-allowlist](app-allowlist.md) and [policy-dashboard](policy-dashboard.md). Those five each assumed a policy model without one having been agreed. Read this file first; where they disagree with it, they are wrong. ## What a policy is **A policy denies.** There are no allows. An empty policy set permits everything, and every policy subtracts from that. The reason is not economy, it is monotonicity, and three properties depend on it: - **Policy sets compose without interaction.** Adding a policy can never widen what is permitted, so there is no shadowing, no precedence rule between sources, and no ordering semantics to get wrong. Any deny from any source is sufficient. - **Order is presentational, not semantic.** Evaluation still runs in a predictable order, but only so that the reason reported first is stable. Nothing about the verdict depends on it. - **Fail-closed is strictly conservative.** When an evaluator cannot answer, the writes it judged are refused. With denials only, a refusal on an unavailable evaluator is guaranteed to be *at least* as restrictive as a complete evaluation would have been. Introduce allows and this stops being true: the unavailable evaluator might have been the one that permitted, and failing closed would refuse something the full set allows. Deny-only is what makes the availability behaviour below coherent rather than arbitrary. **There is no way to except yourself from a built-in policy.** An operator who needs something a built-in denies forks and compiles their own binary. This is a property, not a gap; the first person to hit it will file it as a bug and should be pointed here. ## Where policies come from Three sources. All three deny; none can permit what another denies, so their order is a matter of where a policy is written and not of what it can do. 1. **Compiled into the binary, immutable.** Things unsafe to admit into public existence. An application that does not want agents using it opens a pull request against didbot asking to be denied as a client; that denial ships in the binary. 2. **Supplied at startup, immutable for the run.** Available for deployments that need it. Expected to be unused. 3. **The operator's own records**, polled. `bot.did.policy` carries each denial and `bot.did.policyBinding` says whom it applies to. This source can fail: a policy may name an engine this binary does not have, or the operator's repository may be unreachable. A failed *refresh* keeps the last good set. A failing operator must never widen what an agent may do. A set that has **never** loaded is different, and is handled at onboarding rather than at runtime: see "Pre-flight" below. ## A policy is not its wrapper A policy is separate from the file or record that carries it. That wrapper supplies metadata of two kinds: - **Required**: which language the policy is written in, and therefore which evaluator handles it. - **Optional, and transport-shaped**: which PDS it applies to, a version string of the author's own. That version is *not* this server's version of its evaluation tree, and conflating them will produce a system where an operator cannot tell which of their edits is live. ## Two trees, one set of mechanics Policy is evaluated at two points, with the same indexing, the same evaluator processes and the same outcomes: - **Writes**, whose subject is `(agent, client_id, diff)`. `client_id` is absent for a write made with the account's own credential. - **Sign-ins**, whose subject is `(client_id, requested scopes)`. - **Token issue**, whose subject is `(agent, client_id, scopes)`. The subject shapes differ; the machinery does not. Including `client_id` in the write subject is what lets one app-scoped rule appear in every tree with the same predicate, which the following scenario requires. **Scenario — a token outliving its app's admission.** An agent holds a valid token for an application. A policy denying that application is then added. The application presents its still-valid token and attempts a write. The write tree must refuse it, because the token was issued before the denial existed and nothing revokes it retroactively. A rule that lived only in the token tree would let every already-issued token continue writing until it expired. Note that denying an application is **not** denying a collection. Record types are shared between applications; blocking one client must not stop an agent writing that collection through another. ## Evaluation happens at the write Not at the grant. The rule that forces this: *an agent may edit its bio but not its display name* is a constraint on the **diff**, and a diff exists only at the moment of a write. Anything baked into a token at grant time cannot express it. - [ ] **Evaluate outside the store lock; hold the lock only for the admitted commit.** The sequence number is assigned at admission, not arrival. A slow evaluation must never hold the single writer, and a denied write must never need un-committing — which the write-ahead log cannot do cheaply. - [ ] **Linearize per repository, not per server.** Order within one agent's repository is load-bearing: its commits form a chain, each naming its predecessor. Order *between* two agents' 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 — which is also the fair place for it to land. - [ ] **A limited, explicitly enumerated set of administrative writes bypasses policy.** Not "every XRPC bypasses". The list is written down and each entry justified; anything not on it is evaluated. - [ ] **Say what a delete is.** A delete's diff removes everything, which makes field-level rules read strangely: a policy guarding `displayName` must not be satisfiable by deleting the whole record. Decide and write down whether deletes are evaluated, and against what. ## The tree indexes; the list evaluates The tree is an **index** for finding applicable policies. What comes out of it is an ordered list. The order is part of this server's version of the tree, so that the reason reported for a denial is stable. - [ ] **Split what a policy declares from what it computes.** Applicability is static, declarative data — subject kinds, collections, path prefixes, action kinds — and it is what goes in the tree. Evaluation is opaque: a regular expression, a hand-rolled matcher, a stateful counter, a language this file does not name. The core never needs to understand an evaluator's internals, only its declaration. - [ ] **For diff-shaped policies, the changed paths are the index key.** The diff has to be computed to evaluate anything, so diffing *is* the indexing step: walk the changed paths through the tree, collect what matched, and a write matching nothing has paid only for a walk it already owed. - [ ] **Declare the observation surface the same way.** A stateful evaluator says what it watches as well as what it judges, so the hot path forwards only matching attempts to it and its cost is bounded by declaration rather than by discovery. - [ ] **One evaluator may serve many policies.** If the tree construction makes that an efficient mapping, nothing should prevent it. ## Availability is scoped by the tree The index is not only a throughput optimisation. It decides the blast radius of an evaluator failure. - [ ] **Fail closed per policy, not per deployment.** If the evaluator serving a `tweet` policy is down, only writes the tree routes to it are refused. Records are untouched. - [ ] **Place policies accordingly, deliberately.** A policy gating common writes should be cheap and in-process. Anything needing a sidecar, a network round trip, or a linearized view belongs on a narrow branch, because everything under that branch shares its availability. ## The evaluator contract - [ ] **`compile`** — given a predicate document, return one of three outcomes: compiled (with a `CompiledId` the evaluator itself minted), compiled with warnings (same, plus operator-facing advice that does not block the policy), or rejected with a reason. Validation *is* compilation; there is no separate `validate`. This is what lets `didbot-pds`'s loader decide "this document is syntactically invalid for this language" at load, from the evaluator that actually speaks the language, rather than inferring it from a later evaluation failure. The `CompiledId` `compile` hands back — not `PolicyId` — is what `evaluate`, `observe` and `policy_lifecycle` are addressed by afterward: the compiled artifact belongs to the evaluator, which may run out of process, so a caller-owned handle could only ever be an identifier for state the caller does not hold. - [ ] **`evaluate`** — given a subject and a matched policy, return an outcome. - [ ] **`observe`** — every attempt and every outcome on the declared surface, whether or not evaluation short-circuited. **The decision channel short-circuits; the observation channel does not.** A metapolicy that freezes agents for repeatedly tripping policies must see a denial even when an earlier policy already denied and evaluation stopped. - [ ] **A lifecycle channel** — froze, unfroze, deleted. An evaluator holds per-agent state and will accumulate it for accounts that no longer exist unless deletion reaches it. Unfreeze matters especially: see below. - [ ] **A parallel, policy-scoped lifecycle channel** — loaded, superseded, removed — so an evaluator that accumulates state per compiled artifact (not per account) does not keep it for a policy that no longer exists. Delivery is not reliable and does not need to be: the tree decides what is routed anywhere, so a missed removal is a memory leak, not a correctness problem. No evaluator in this workspace accumulates per-artifact state yet (`didbot-policy-regex` holds exactly one, replaced wholesale on every recompile), so nothing here calls this channel today — it exists on `Evaluator` (defaulted to a no-op) for the evaluator that will need it. ### Last-good-revision A policy that compiled at revision N and fails to compile at N+1 keeps enforcing N — dropping it would widen what an agent may do, and denying everything it covers would turn a typo into an outage. `didbot-policy-source`'s `LastGoodPolicies` holds the last successfully-compiled record per operator-sourced id, across every `merge::build` call; a revision that fails to compile is looked up there and, if found, recompiled and enforced in the new revision's place, reported as a `LastGoodFallback` — distinct from a `ScopedDenial`, which is what a policy that has *never* compiled still gets, loudly, because there is nothing to fall back to and a first-publish typo must not silently deny its own coverage. `startup` (source 2, immutable for the run) has no last-good-revision fallback: there is never a second revision to have fallen back from. - [ ] **Evaluators own their state**, built from what they have seen. Not a re-index of historical log state. State that survives nothing is acceptable; state that must be reconstructed from the whole log is not. - [ ] **Stateful evaluators see attempted writes, not only admitted ones.** An agent hammering denied writes is exactly what a rate limit should catch. The consequence is that their state is *not* derivable from the log and a restart genuinely loses it. Windows must be declared so the memory is bounded by window × agents, and the reset-on-restart behaviour must be stated rather than discovered. - [ ] **Out of process, by design.** An evaluator may be a separate program, possibly in another language, reached over a shared-memory queue. Two reasons: it is a separate failure domain, and — more importantly — a deadline against an in-process evaluator is cooperative and therefore not a deadline. Across a queue the response slot can be abandoned. ## Deadlines, lag, and adversarial policies - [ ] **Two budgets, not one.** *Decision latency* is per request and blocking. *Observation lag* is how far behind a stateful evaluator's state may be. - [ ] **Staleness is the evaluator's declaration.** `Linearized` for a deterministic evaluator whose answer is exact and cheap — it blocks rather than answer stale, so its lag bound *is* its latency bound, and it is a throughput ceiling that belongs on a narrow branch. `BestEffort` with a stated maximum lag for anything with latency spikes — a model in the loop, a network resolution — which answers from what it has seen. - [ ] **Exceeding the lag bound is a health failure**, routed into the same fail-closed-scoped-by-the-tree path as a crash. A lagging evaluator and a dead one are the same event to the tree. - [ ] **The deadline is a security parameter.** Policies are public, so their inputs can be tailored. In process this is structurally solvable: a linear-time regular expression engine cannot be made to blow up. Out of process it is not solvable at all, and the caller-enforced deadline is the entire defence. - [ ] **Attribute a timeout.** A denial and a timeout are indistinguishable from the agent's side, so a slow policy silently becoming deny-everything looks like a working system. The operator sees `policy P exceeded its budget on N writes`. ## Outcomes `allow`, `reject`, `freeze`. - [ ] **Freeze is a transition on the repository's queue**, not a message sent beside it. The first write that trips it drains that repository's pending writes with a rejection naming the freeze — which is both correct and the only way the agent gets a coherent account of what happened. - [ ] **An evaluator can never unfreeze.** Only an operator, explicitly. But the evaluator must be told, or the state that triggered the freeze survives the unfreeze and slams it shut again on the next attempt. ## What is written down, and what is not - [ ] **Detail flows to the party that already has it.** The rejection returned to the caller may be as specific as the policy likes, naming paths and quoting what tripped, because the caller sent the data. didbot typically controls the client, so what was sent is knowable there. ## Reading records this deployment did not design The motivating policy: *an agent never mentions someone who has opted out of AI*. Evaluating it requires the lexicon schema for a record type didbot did not design, to find which fields hold a DID; a cache lookup per DID found; and a network resolution on a cache miss. - [ ] **A generalized object reader**, over lexicons this deployment does not hold — including ones that are not publicly published, as Tangled's are not. - [ ] **Schema availability is a precondition, not a fallback.** Walking a record structurally to find anything DID-shaped needs no schema, but it makes behaviour depend on whether a schema happened to resolve. That is observable to whoever writes the record — publish your lexicon for precise checking, withhold it for conservative checking — which is a perverse incentive inside a security control, and a policy that "did something different today" is close to undebuggable. So a policy needing introspection declares it, and an unresolvable schema is the fail-closed-scoped-by-the-tree path: `cannot check mentions in sh.tangled.foo: no schema`. - [ ] **Two things make that livable**: an operator can supply schemas for lexicons they care about, and resolved schemas cache, so "published" is a one-time resolution and not a per-write variable. - [ ] **Evaluation may perform I/O.** This is what makes the deadline structural rather than defensive, and the cache the evaluator's own state rather than the core's. ## Pre-flight, so there is never an unpolicied instance - [ ] **The operator writes policies after provisioning and before `CLAIMED`.** A deployment must never be online and serving without the policies its operator intended. This lands in the onboarding flow as a pre-flight check. - [ ] **The check cannot be "does any policy apply".** Built-ins always apply, so that passes vacuously. It is: operator-sourced policies are loaded, **or** the operator has explicitly acknowledged running on built-ins alone. ## The operator sees the tree - [ ] **Render the evaluation tree on the operator dashboard**, including which evaluator service each policy decomposes to and whether it is healthy. This matters more once evaluators are stateful and out of process: a policy that is loaded, a policy that is loaded and lagging, and a policy whose evaluator is missing from this binary are three different states an operator must be able to tell apart. - [ ] **Show where each policy came from** and, for polled ones, how far behind the last successful pull is. ## Not this epic Two neighbouring epics use "policy" in a different sense and are not governed by anything here. [name-pools](name-pools.md)'s **reclaim policy** is a rule about when a handle returns to its pool; [did-minting](did-minting.md) mentions a policy only to say that making reuse negligible is preferable to enforcing one. Neither is an agent policy, neither is evaluated by the tree below, and neither denies a write. ## What a policy is keyed on, and what that key is worth A rule that applies to "agents of this type" is keyed on something, and the strength of the whole rule is the strength of that key. - [ ] **Say what `agent_type` is worth, where a rule uses it.** `AgentAccount::agent_type` is the harness's own word for what it is — supplied in the provisioning request, not a checked fact, one tier below an attested node in `docs/trust-model.md`'s terms. The running agent cannot change it and the model never sets it, but whoever may provision chooses which rule an account falls under. A policy scoped by agent type is therefore exactly as trustworthy as the thing that provisioned the account, and that sentence belongs next to any rule that uses it. - [ ] **A default that is narrow, with generous types named explicitly.** An unknown or unrecognised type must fall to the narrow rule rather than outside every rule. The failure of a typo'd type should be an agent that can do less than intended, never one that escapes the rules entirely. - [ ] **`SubjectKind` and `agent_type` are different keys and must not be conflated.** Kind is this server's own classification of an account (`Agent`, `Pipeline`, `Host`, `Service`) and is not the caller's to assert; type is the harness's label. A rule keyed on kind is worth more than one keyed on type, and a reader should be able to tell which they are looking at. ## Configuration may narrow, never grant `didbot-config`'s own documentation, `plan/config.md` and `docs/deployment.md` all say that collection permissions belong in the owner's records rather than in this server's configuration file, and that a reviewer should reject a configuration key that adds "a scope, a permission, or an allowlist of apps" on that question alone. That rule holds, and there is a shape it does not forbid, worth writing down before somebody argues it either way in a review: - [ ] **Decide whether a narrowing-only configuration key is admissible.** A key that can only *subtract* from what an agent could already do — the same property `[disclosure]` has — cannot be used to grant anything. Somebody who holds the host can lock an agent out with it and cannot let one in. Whether that is enough to make it acceptable, given that the accountable statement is meant to live in the owner's records, is a question about what configuration is *for* rather than about what it can do. - [ ] **If the answer is yes, say so in all four places at once.** `didbot-config`'s crate documentation, `sections.rs`'s header, `plan/config.md` and `docs/deployment.md` each state the prohibition independently. A narrowing exception recorded in one of them is a contradiction a future reader resolves by picking whichever they read first. ## Deliberately unsettled These need iterative design and must not be invented by whoever implements first: - **The shape of the required and optional wrapper metadata**, beyond the two kinds named above. - **Which languages ship**, past a deterministic in-process regular-expression evaluator for the simplest cases — which is also what makes an application denial an early, cheap rejection near the root of the tree. - **The relationship to the atproto scope grammar.** Policy is the ceiling; whether scopes remain as a coarse projection for third-party clients that expect them is open. - **Whether deletes are evaluated**, per the item above. ## Consent is a denial, evaluated at authorize An agent has nobody at a consent screen, so the question at authorize is not whether somebody approved an application — it is whether this one is denied. That is this epic's own model applied one step earlier, and the machinery is already shaped for it: the grant is a subject kind of its own, and denying an application by its client identifier applies to a grant request as much as to a write. - [ ] **Permit-unless-denied, but only when a tree is loaded.** "No denial" is a decision; "policy could not be read" is not. The consent seam's own invariant — approval must never happen merely because there was nobody to say no — stays true if absence of a *denial* answers and absence of a *source* refuses. The last-good machinery is what tells the two apart. ## Done - [x] **A blob upload is judged, under `blob.write`.** The account, the type the uploader declared, the type read from the blob's own bytes and its size are a `didbot_policy::Subject::Blob`. It is judged twice: on what the request declares, before the body is read, so a blob a policy refuses is never carried; and again once the bytes are in and before they are stored (`didbot_pds::provision`'s `JudgedUpload`). A fact the first judgment lacks is absent rather than guessed, and cannot deny. A refusal discards the upload and answers `PolicyViolation`. The regex engine's `denyBlobUnless` holds an upload to the types and the size an operator names, and Cedar's `blob.write` action takes the same facts. Tests: `crates/didbot-serve/tests/blob_policy.rs`. - [x] **Record which policy version admitted each write, and which app made it.** The system exists for accountability, and "was this write legal under the policy in force at the time?" is unanswerable unless the write remembers. So is "what did this app write?", which is the first question after denying one. Each record a caller writes or deletes is a row in the write log (`didbot_pds::write_log`) once its commit lands: the revision, the key, what the write did, the client whose OAuth token presented it, and the digest of the operator revision the write pinned when it was judged (`PolicyGate::judged_revision`). One row per write is the volume, and a separate file with its own lock and a thirty-day retention is where it goes; the refusal path never writes one, and neither does a write this server makes itself. - Source 3 is read and enforced. `didbot_serve::policy_poll::PolicyPoll` lists both collections on the ownership poll's tick, builds the set whole through `didbot_pds::policy_bindings::resolve`, keeps the build last refused for an operator to read (`PolicySet::rejected`), and swaps the tree into the gate; `bot.did.stats` publishes the set's digest as `policy.enforced`. - Bindings resolved within each account's boundary (`crates/didbot-pds/src/policy_bindings.rs`). `resolve` builds the whole set from one observation of both collections or refuses it, naming every record it cannot honour in `RejectedBuild`; a refused build leaves the set last built serving. The gate judges a write within `Registry::boundary`'s chain, so a binding on a principal reaches every account beneath it, minus its `excludes`; the server's own DID as a subject stands for the whole server, and a grant judged before any account has signed in is reached only that way. - The two lexicon documents, `lexicons/bot/did/policy.json` and `lexicons/bot/did/policyBinding.json`, and the reader for each (`crates/didbot-policy-records/src/records.rs`). A policy names the `actions` it is consulted on and a `document` typed by the engine that reads it; a binding applies policies, each named by AT-URI, to its `subjects`, their `descendants`, or both, minus its `excludes`. The policies must currently be in the same repository as the binding; a reference into another repository is not supported yet. - [x] **Say that the evaluator runs in every place a client is judged.** The same gate judges a `Subject::Grant` at the pushed authorization request (`didbot_serve::oauth::par::push_authorization_request`), a `Subject::Token` at `POST /oauth/token`, and a `Subject::Write` on the write path (`didbot_pds::writequeue`). A write presented by an OAuth token carries the client that token was issued to (`didbot_serve::auth::Writer`); a write made with the account's own credential carries none. So a rule scoped to a client answers the "token outliving its app's admission" scenario above: the token it already holds is refused at the write and at refresh. End to end in `crates/didbot-serve/tests/denied_app.rs`. - [x] **A policy that denies a client only before the write is reported.** Its tokens keep writing until they expire, which is a choice an operator may make but should see: `BindingReport::spares_held_tokens`, logged at `warn` by the policy poll. - The `compile` step on `Evaluator` (three outcomes, a `CompiledId` the evaluator owns, a parallel policy-lifecycle channel) and last-good-revision (`crates/didbot-policy`, `crates/didbot-policy-source`, `crates/didbot-policy-regex`, `crates/didbot-pds/src/policy_tree.rs`). - [x] **A freeze names why.** `AccountState::Frozen` needs a reason, because an account can be frozen by an administrator, by a lifetime rule, or by a policy — and these are not the same event. The reason carries an evaluation id; the log holds which policies fired. `AccountState` itself stays the bare `Frozen` variant it already was. The reason instead lives on `LedgerEvent::StateChanged`'s new `policy_evaluation: Option` field (`crates/didbot-pds/src/ledger.rs`) — absent for an administrator's or a lifetime rule's freeze (which still use `operator`), present for a policy's, carrying the id `crate::evaluation_log::EvaluationLog` minted for the judgment that tripped it. `FreezeSyncingGate::froze` (`crates/didbot-pds/src/provision.rs`) reads the id off `PolicyGate::last_evaluation_id` and writes the ledger entry. - [x] **Decisions are not reproducible, and the log must not assume they are.** Two independent reasons: an evaluator may have a model in the loop, and an agent may delete data the evaluation read. Short of versioning all data immutably there is no general promise here. So the log records **the decision**, not merely its inputs and version. This is cheap now and impossible to retrofit once there is history nobody can explain. `crate::evaluation_log::DenialRow` (`crates/didbot-pds/src/evaluation_log.rs`) carries `closed_at`, the `now` the judgment actually ran under, rather than anything an operator could use to re-derive the verdict later. - [x] **A denial records no part of the refused payload.** The refused data is by definition what a policy decided should not exist here: storing a write refused for naming an opted-out DID puts that name durably on disk, in a log with its own retention, written by the mechanism meant to prevent it. The same holds for anything refused as abusive or unlawful. A denial row carries an evaluation id, the policy version, which policies fired, the verdict, a hash of the payload, and a reason string the **policy** authored — the policy being the only thing that knows which parts of its input are safe to surface. `DenialRow` is exactly that field list, and nothing else; `hash_subject` is the one place the module reads a payload, over which it produces only a SHA-256. `evaluation_log::tests::a_row_never_carries_the_refused_value` asserts a row's own serialized JSON never contains the refused value. - [x] **Bound denial rows.** Even without payloads, a row per attempt is attacker-driven write volume. Collapse repeats — same agent, same policy, same reason, a count and a window — and rely on a metapolicy to freeze before it matters. Both: the metapolicy needs several attempts to trip, and the collapse covers the interval before it does. `EvaluationLog` keys an in-memory, bounded collapsing window on (account, client, policies fired, reason); a repeat inside the window advances a count rather than writing a row, and the row lands once the window closes — on a later attempt outside the window, an eviction past `DEFAULT_MAX_OPEN_WINDOWS`, or a deployment's own periodic `flush_stale`. Both halves are covered: the map bounds memory during an active spree, the row is what a metapolicy's freeze (which needs several attempts to trip) has time to land before. - [x] **A debugging capture is an explicit operator action** with its own short retention, never something the normal path does quietly. `evaluation_log::CaptureSink` is the seam: nothing in this crate wires one in, `EvaluationLog::with_capture` is the only way one is ever called, and it fires only for the write that opens a fresh window — never a collapsed repeat. Its own retention is the sink's business, not `EvaluationLog`'s.