--- id: write-policy title: Which agents may write which record types status: open crates: [didbot-pds, didbot-serve] dependsOn: [pds-writes] exitCriterion: > An agent whose profile does not permit a collection is refused when it writes one, and the refusal names the policy. --- # write-policy **Superseded.** [policy](policy.md) settles the model this epic assumed — denials only, evaluated at the write, from three sources — and where the two disagree, this file is wrong. Read that one first. The server refuses collections it does not define and records that fail their lexicon. Neither is policy: both give the same answer for every agent. Missing is the per-agent question — this one may record and not remember. The natural key is the agent's type, which is what distinct subagent DIDs are for. - [ ] **A collection permission per agent, attached at provisioning.** - [ ] **Who may write an account's own profile, and its picture.** The server draws an avatar at provisioning and writes `app.bsky.actor.profile`; every later write merges into what is stored, so an account can already replace both. **The factual half of this changed and this item used to say otherwise.** There *is* authentication on a repo write now: `createRecord`, `putRecord`, `deleteRecord` and `applyWrites` all go through `auth::require_agent_token_or_dpop`, which resolves an agent token or a DPoP-bound OAuth access token to a DID, and `routes::write_record` refuses with `repo_mismatch` when the resolved DID is not the repository named. So today's answer is "the account itself, holding a credential this server issued" — not "anyone who can reach the server". What is still undecided is the policy on top of that: whether an agent *should* be able to set its own picture and its own profile fields at all. Whether an agent may set its own picture, and through what, is a policy question rather than a drawing question: an avatar arriving from outside is a claim about an identity, and the rule that a profile is identity and never comes from the model is written in `didbot-pds`'s profile module. Settle it here before any tool offers it. ## Done - [x] **Enforce it at the write, not only in the token.** A rule about what an account may ever write has to hold for sessions issued before the rule changed. This is also where withdrawal happens: the profile defines no revocation endpoint, and none is needed when the server refusing the write is the one holding the data. The policy gate judges every write, so a denial refuses a token issued before it: `crates/didbot-serve/tests/denied_app.rs` holds a login across a denial and sees its next write refused, with nothing revoked. - [x] **A distinct refusal**, so "your policy forbids this" is not read as "this record is malformed". A policy refusal is `403 PolicyViolation`, carrying the policy's own reason, where a malformed record is `400 InvalidRecord` (`crates/didbot-serve/src/error.rs`); `crates/didbot-serve/tests/cedar_write_routes.rs` reads it back from every route that writes a record. - [x] **From server configuration first, from the operator's records second** ([policy-store](policy-store.md)). The server's own half is the floor compiled into the binary (`crates/didbot-policy-records/floor/`), and `crates/didbot-serve/src/policy_poll.rs` builds the operator's `bot.did.policy` and `bot.did.policyBinding` records into the gate every write is judged by. - [x] Collections outside this deployment's namespace are refused. - [x] `$type` inside a record may not contradict its collection. - [x] Records validated against their lexicons, explicitly, on write.