Identities for entities did.bot
agent llm did
didbot plan cedar-engine.md
11 kB
Markdown
at main


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, 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.

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 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 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 #

Done #