--- id: cedar-engine title: Cedar is the second engine, and it never permits by accident status: open crates: [didbot-policy, didbot-pds, didbot-lexicon] dependsOn: [policy] exitCriterion: > A bot.did.policy with engine cedar refuses a write, the refusal carries the statement's @id and @reason, a document with a typo in a field name refuses every write in its scope rather than permitting one, and a policy over a collection with a public lexicon is rejected at load when it reads a field that lexicon does not have. --- # cedar-engine The envelope names its engine. The first is the regex engine; the second is [Cedar](https://www.cedarpolicy.com/), through the `cedar-policy` crate. Cedar brings a typed schema, a validator, time arithmetic and a hierarchy operator that matches the admission tree. It also has three behaviours that are wrong for a deny-only server unless the engine is built around them, and this epic exists to write those down before the crate is added. Everything below was checked against Cedar 4.12 (language 4.5) with the four documents and ten requests in the policy lexicon workshop. ## The request model One Cedar namespace, `Didbot`. The principal is always the account. The account tree is Cedar's entity hierarchy: `entity Account in [Account]`, with each account's parents being the accounts that admitted it. Reach still lives in the binding, which decides which documents load at all; a document may narrow further with `principal in`, and that is allowed and unremarkable. Each action is one Cedar action with a fixed resource type and context record. This table is the schema, and the schema is generated from it, not written twice. | action | resource | context | |---|---|---| | `record.write` | `Record { collection, rkey, space? }` | `operation`, `client?`, `value?`, `existing?`, `now` | | `blob.write` | `Blob { mimeType, sniffed, size, space? }` | `now` | | `oauth.authorize` | `Client { id }` | `scopes`, `now` | | `oauth.token` | `Client { id }` | `grant`, `scopes`, `now` | A write's `client` is the `Client` its OAuth token was issued to, the same entity the two OAuth actions take as their resource. So `context.client.id like "https://app.example/*"` at the write reads like `resource.id like "https://app.example/*"` at sign-in. A write made with the account's own credential has no client, and a statement tests `context has client` before reading it. The principal of every action is an `Account` carrying `kind`, `handle` and `depth` (edges between it and the operator) when the gate knows them; a statement guards each with `has`. `now` and every timestamp are Cedar `datetime` values, so a document can say `context.now.durationSince(context.existing.createdAt) > duration("1d")`. A create over a tombstone arrives with `existing` populated and `operation` set to `create`, per [account-data](account-data.md). ## Three rules the engine enforces **Every statement loads under its `@id`.** Handed a document as one text, Cedar numbers the statements `policy0`, `policy1`, and a refusal names nothing. The loader splits the document into statements, requires `@id` on each, requires them to be unique within the document, and loads the set keyed by them. A forbid also requires `@reason`; the refusal on the wire is the envelope's `name`, the statement's `@id` and its `@reason`, the same three things the regex engine returns. **An evaluation error is a refusal.** Cedar treats a policy that errors as not satisfied. For a permit that is safe. For a forbid it means a misspelled field name, or a missing `has` guard on an optional field, permits the write and tucks the error into the diagnostics. The engine reads the diagnostics after every decision and refuses when they hold any error, naming the statement and saying the check could not complete. A document with a typo therefore refuses everything in its scope, which is loud, rather than permitting silently. **A document may only forbid.** Until [policy-exemptions](policy-exemptions.md) ships closed subtrees, the server's baseline is one `permit(principal, action, resource)` and every loaded document is checked at build time to contain forbids only. A permit in a document is a load error on the dashboard, because a permit that does nothing today would start doing something the day closure exists. ## Validation, and what happens without it Cedar validates a document against a schema at load, in strict mode. The schema's record types are generated per collection from the lexicon the server knows for it, either shipped in `didbot-lexicon` or resolved through the published `com.atproto.lexicon.schema` record for the NSID's authority. Validation happens in the candidate build that [policy](policy.md) already describes: the current set keeps serving, the candidate is built and validated in memory, every failure is reported to the dashboard, and the swap happens only when the candidate is clean. Seeing a new collection for the first time triggers a resolve and a rebuild. A collection with no resolvable lexicon is evaluated **unvalidated**: Cedar runs without a schema and reads whatever attributes the record has. This works, and it was tested: nested fields, `like` on a nested URI, and set membership all evaluate. What it loses is catching mistakes at load. A typo surfaces at the first write that reaches the path, as a refusal under the rule above, and the dashboard shows evaluation errors as a second-class signal beside load errors. The open-record schema feature that would let a schema cover the typed part and leave the record open is experimental in Cedar and is not used. ## What Cedar cannot say - **Regex, or case folding.** Strings have `like` with `*`. A content rule over free text is a regex document. - **Order.** JSON arrays become Cedar sets. A rule about the first image or the order of facets is not a Cedar rule. - **Counts or history.** Cedar sees one request. Counters and historical facts are the stateful evaluator's, in the resolver and state seams the policy epic keeps open. ## Not this epic Templates and template links, partial evaluation, and the datetime extension beyond `now`, `durationSince` and `duration` literals. Each is a seam Cedar leaves open and none has a scenario yet. ## Work - [ ] Schema generation from lexicons, the resolve-on-first-sight rebuild, and the unvalidated path for a collection with no lexicon, each flagged to the dashboard. - [ ] Each gate hands the engine the account's admission chain. `Request::build` makes the chain into the principal's parents when a gate passes one, and no gate does: `crates/didbot-policy-cedar/src/shared.rs` builds every principal with `admitted_by: None`, so a statement scoped with `principal in` refuses every request it is asked about. - [ ] Each gate hands the engine the whole table. Today `record.write` gives no `rkey`. A create over a tombstone arrives as an update. `oauth.authorize` gives no account, and `oauth.token` no `grant`. `blob.write` gives the account, the declared and sniffed types and the size, and no `space`. A statement reading what its gate leaves out refuses, so `no-late-edits` reads `createdAt` as the string it is stored as rather than a `datetime`, while no shape describes posts. ## Done - [x] **The `cedar` engine behind the evaluator contract**, in its own crate beside the regex engine, with the four workshop documents and ten requests as its tests. `crates/didbot-policy-cedar` is the engine; `didbot_pds::policy_tree::CedarLanguage` registers it for `bot.did.policy#cedar`, and `policy_bindings` registers each statement under `{key}/{@id}/{action}`, so a refusal reports the statement the way a regex statement is reported by its index. - [x] **The loader**: statements split and keyed by `@id`; `@id` unique and required; `@reason` required on forbids; forbid-only enforced; every failure a build result, not a log line. `Document::load` splits a document with Cedar's own parser and reports every failure at once by statement. `Shapes` holds one schema per described collection; a statement is validated against each described collection it names, or every described one when it names none, and a statement that reads the record of a collection with no shape loads with an `unvalidated for` warning that reaches `LoadReport::warnings`. The server describes no collection yet, so every record read is evaluated unvalidated. - [x] **Error-is-refusal**, with a test that a misspelled field refuses and names the statement. `Document::decide` reads the diagnostics after every decision and refuses on any error, naming the statement. - [x] **`record.write` carries the client**, as `context.client`, so a Cedar policy refusing an app at token issue can refuse the tokens it already holds at the write. `spares_held_tokens` reports a Cedar policy refusing at sign-in or token issue only when none of its `record.write` statements reads `context.client`. - [x] **Entities per request**: the resource and context for the action. `Request::build` makes every entity and context in the table. A statement whose scope needs what the request does not carry refuses rather than never matching. - [x] **`value` and `existing` are the records themselves.** The write path holds both sides already and hands them to the gate whole, on `Subject::Write`, beside the diff the applicability index walks. A record cannot be recovered from a diff: a path string is not a parse of the key that produced it, so two fields of one record can spell one path, and an update's diff names only what it touched. Both were ways for a statement to be asked about something the repository does not hold. - [x] **A write's request is built once.** Every statement asked about a subject judges one request built for it, whichever evaluator holds the statement. Each thread keeps the last request it built, and a subject reuses it only when equal in every field (`crates/didbot-policy-cedar/src/shared.rs`). - [x] **What a thread keeps between writes is bounded.** The kept request holds the record the write carried, so it is kept only while the reuse it exists for can happen: the next miss on any thread drops every entry idle for five seconds, and a subject over 64 KiB is judged from a request built the same way and then not kept at all. Both numbers are constants in `shared.rs` rather than configuration — they bound the engine's own working set, not anything an operator sizes.