Policy evaluators #
The reference for the engines that judge a write: what one is, the six channels it answers on, the order they run in, where its state lives, and what happens when it fails. plan/policy.md is the reasoning behind this design; this page is what the code does. write-pipeline.md is where these stages sit in a write's journey.
Engines and policies #
An engine is a Rust implementation of the Evaluator trait
(crates/didbot-policy/src/outcome.rs). It holds compiled statements and
answers about subjects. It is the only part of policy that is Rust.
A policy is a bot.did.policy record
(lexicons/bot/did/policy.json). Its document is a union member whose
$type names the engine that reads it, and the statements inside it are
that engine's own grammar. An operator writes one into their repository;
the floor ships them in crates/didbot-policy-records/floor/. Both are
read by the same code.
Three engines ship:
$type |
Crate | What a statement is |
|---|---|---|
bot.did.policy#regex |
crates/didbot-policy-regex |
an object whose kind says what it denies — a client, a path change, a changed value, a delete, an upload — with regular expressions for what to match |
bot.did.policy#cedar |
crates/didbot-policy-cedar |
one forbid in Cedar text, carrying an @id and a @reason; the document's text holds all of them |
bot.did.policy#count |
crates/didbot-policy-count |
an object naming the writes it counts, whether it counts each account or the whole server, its window and its limit |
Each crate's own documentation holds its statement fields. A document of a
$type no engine answers to is carried as far as the loader, which scopes
it as unusable rather than dropping it — see Availability below.
The contract #
Six channels, all synchronous. No method is async, and
crates/didbot-policy has no I/O and no clock of its own: an engine that
needs either does it out of process, with the caller holding the deadline.
compile #
fn compile(&self, predicate: &serde_json::Value) -> CompileOutcome
Turns one statement into an artifact the engine can judge, and mints the
CompiledId that names it. Three outcomes: compiled, compiled with
warnings an operator should see, or rejected with a reason.
Called from the loader, once per statement per tree build
(crates/didbot-policy-records/src/compile.rs), never from the write path.
Validation is compilation: there is no separate validate step, because
only the engine knows its own syntax. It must be idempotent — the same
statement compiles again on the next build, and on an out-of-process
engine's restart.
The caller keeps the id, never the artifact. An engine that holds one
artifact per instance gets one instance per statement; an engine that keeps
several must mint a distinct id per compile and dispatch on it
(Language::new_evaluator, same file).
evaluate #
fn evaluate(&self, subject: &Subject<'_>, compiled: CompiledId)
-> Result<Outcome, EvalError>
Judges one subject against one compiled statement:
Outcome::Allow, Reject { reason } or Freeze { reason }, or an
EvalError when it could not judge at all.
It may read only what Subject carries
(crates/didbot-policy/src/subject.rs): the account and its attributes,
the client, the collection, the action, the diff, the record before and
after, the answers to the policy's declared lookups, and Universal,
which holds this deployment's own DID, hostname and operator and the
instant the decision runs at. It may not call a clock, reach a store, or
fetch anything — the signature is the enforcement, since nothing
reachable from its arguments can. What it reads of another repository was
read before it was asked; see Lookups below.
The reason is the policy's own words. It is what the caller is told and what the evaluation log records, so it is the engine's job to say only what is safe to surface.
The tree short-circuits this channel: once a verdict cannot get more
severe, the remaining candidates are not asked
(crates/didbot-pds/src/policy_tree.rs, decide).
observe #
fn observe(&self, event: &Observed<'_>)
Every attempt on the policy's declared observation surface, with the subject, the compiled id and the outcome — whether or not evaluation short-circuited, and whether the write was admitted or refused. It runs after the decision, so a statement judging a count judges the count before this write and then counts it.
This is where an engine's state changes. evaluate takes &self and so
does this, so an engine that accumulates does it here.
The write path calls it for every operation of a batch
(crates/didbot-pds/src/writequeue.rs, submit_batch). Nothing after a
freeze is judged or observed: the freeze drains that repository's queue.
lifecycle #
fn lifecycle(&self, event: &Lifecycle<'_>)
Frozen { account, reason }, Unfrozen { account }, Deleted { account },
delivered to every engine in the current tree
(crates/didbot-pds/src/policy_tree.rs). It is how per-account state does
not outlive its account: crates/didbot-pds/src/provision/lifecycle.rs calls
deleted when an account goes.
An engine whose state caused a freeze must act on Unfrozen, or the freeze
re-trips on the next write.
policy_lifecycle #
fn policy_lifecycle(&self, event: PolicyLifecycle) { }
Loaded, Superseded and Removed, per compiled artifact rather than per
account, for an engine that keeps state per statement. It is defaulted to a
no-op, so an engine that keeps none says nothing.
Delivery is deliberately unreliable: the tree decides what is routed anywhere, so an artifact nobody told the engine about costs memory and cannot change a verdict. Nothing here retries or acknowledges.
staleness #
fn staleness(&self) -> Staleness
Linearized for an engine whose answer is exact and cheap: it blocks
rather than answer from a view that has fallen behind, so its lag bound is
its latency bound. BestEffort { max_lag } for one that answers from what
it has seen; a view further behind than max_lag is a health failure, and
the engine reports it as EvalError::Unavailable, which the tree treats
exactly as it treats a crash (crates/didbot-policy/src/outcome.rs).
All three engines here declare Linearized. Each policy carries the
declaration too (ParsedPolicy::staleness), because one engine may
one day serve statements of different kinds.
Lookups #
Not a channel of the trait: the answers arrive on the subject evaluate
is handed. A policy record may declare lookups: records in other repositories its
statements read, each under a name, with whose repositories to read
(named is every account the written record names), the collection, and
optionally a record key (#lookup in lexicons/bot/did/policy.json;
Lookup in crates/didbot-policy-records/src/records.rs). They are read
before the write is judged, never during it:
Lookups::ofgathers what every policy in the build declares, andfor_writeanswers which of them a write to one collection needs (crates/didbot-policy-records/src/lookups.rs).named_accountssays whose repositories a written record names (crates/didbot-policy-records/src/named.rs).- The route that accepted the write fills
didbot-lookup's cache with those records, inside that crate's own deadlines, while it still has a runtime (Reader::prefetchincrates/didbot-serve/src/lookups.rs). - The write path reads the same cache, with no I/O, and attaches the
answers to the subject as
Subject::Write'slookups(WriteLookupsincrates/didbot-pds/src/policy.rs). Each answer is aLookupwhoseFoundis the records, nothing, or unknown.
An engine reads an answer by the name the record gave it; the regex
engine's denyLookup statement is one that does
(crates/didbot-policy-regex). An unknown is never turned into a value:
each statement says what an unknown means for it. The floor's
declared-ai-preference record is the worked example.
The order, per write #
Matching the stages in write-pipeline.md:
- On a poll tick, not on a write, the operator's records are listed
and the whole tree is rebuilt:
didbot_serve::policy_pollreads them,didbot_pds::policy_bindings::resolvesplits every record into one declaration per statement per action,compileruns once for each, and the new tree is swapped into the gate. A write already being judged keeps the tree it pinned. - Lookups are read by the route that accepted the write, for the policies that could be asked about it — see Lookups above.
- The subject is built by
didbot_pds::policy::write_subject, which stampsUniversal::nowfrom the clock once, for this write, and carries the lookups' answers. Every engine reads the instant from there, and the evaluation log records the same one. - Stage 6, the applicability index: the tree walks the write's changed paths, collection, action and account kind, and produces the ordered list of candidate policies. A write matching none has paid only for the walk it already owed.
- Stage 7,
evaluate, in that order, skipping a policy no binding reaches this account with. Outcomes fold by severity, so the order decides only which reason is reported. observe, over the observation surface of the same pinned tree, for every attempt.- The version: the write records the tree generation and the digest of
the operator revision it was judged under (
PolicyGate::versionandjudged_revision). - The evaluation log, for a denial only: the evaluation id, which
policies fired, the verdict, a hash of the payload, the policy version,
the instant, and the policy's own reason — never any part of the refused
value (
crates/didbot-pds/src/evaluation_log.rs).
Stages 6 and 7 run outside every store lock, so a slow engine holds none.
Where state lives #
An engine is constructed fresh for each statement on each tree build, and the tree is rebuilt on every poll tick. So state that must survive a tick cannot live in the engine.
didbot-policy-count is the worked example. Its counts live in one
Counts per process (crates/didbot-policy-count/src/counts.rs), and each
statement's count is held under the statement's own sorted JSON: a rebuilt
engine compiling the same statement finds the count the last one left, and
an edit that changes a limit keeps the count the old statement was keeping.
Two statements written differently count for themselves, so an operator's
own counting policy over the same surface can only refuse more.
The counts are memory only. A restart is a fresh window, for every account and for the deployment; that is an operator action the deployment records, and it is not reachable by the accounts the counts bound.
The bounds are declared rather than discovered: a ring of BUCKETS hourly
counters and a head per account per statement, about 150 bytes with a DID
for a key, and at most HELD of them — past that, counts whose whole ring
has aged out are dropped. Counts for a deleted account go on lifecycle.
Availability #
Failure is scoped by the tree. An EvalError from evaluate, and a
statement whose predicate could not be compiled at all
(Predicate::Unusable), both refuse exactly the subjects that policy
declared it covers, and nothing else: PolicyTree::decide turns each into
a Reject on the spot (crates/didbot-policy/src/index.rs). A policy
gating common writes therefore belongs in process and cheap, and anything
slower belongs on a narrow branch, because everything under that branch
shares its availability.
This is strictly conservative only because policies deny and never permit: a refusal on an engine that could not answer is at least as restrictive as the full evaluation would have been.
Outcome::Freeze is the third answer the contract allows: it drains that
repository's queue and needs an operator to lift it. Every floor statement
refuses the write and nothing more.
The floor and an operator's records #
Both are bot.did.policy records and both go through the same build
(crates/didbot-policy-records/src/merge.rs, build): the floor is source
1, the startup-supplied set is source 2, the operator's records are source
3, and every one of them is read by the same reader, split by the same
declarations, and compiled against the same engines by
crates/didbot-policy-records/src/compile.rs. The floor's records are in
crates/didbot-policy-records/floor/, with README.md beside them saying
what each is for and where its numbers come from.
All three sources deny, so their order decides which reason is reported first and nothing else. An operator's record can narrow what the floor leaves and cannot widen it.
A built-in registers under builtin/{name}/{statement}/{action}
(crates/didbot-policy-records/src/builtin.rs). That is not an at-uri, and
bot.did.policyBinding.policies holds at-uris into the operator's own
repository, so a binding has nothing to name: the floor is unaddressable by
construction rather than by a check.
crates/didbot-pds/tests/floor.rs holds that.
Adding an engine #
- A crate beside the others,
didbot-policy-<name>, implementingEvaluator. - A union member in
lexicons/bot/did/policy.jsonand the matching arm incrates/didbot-policy-records/src/records.rs, so the reader carries the document. - A
Splitarm incrates/didbot-policy-records/src/set.rs, saying which subject shapes each statement decides. - A
Languagebridge and anENGINESentry incrates/didbot-policy-records/src/engine.rs. - A policy in
policy-site/recommended/orcrates/didbot-policy-records/floor/with its cases beside it, which the harness incrates/didbot-pds/tests/cases/mod.rsruns through a real gate.