didbot_policy/outcome.rs
1//! The evaluator contract: what a policy decides, how stale its view may be,
2//! and the three channels an evaluator is asked to answer on.
3
4use std::time::Duration;
5
6use crate::policy::{CompiledId, PolicyId};
7use crate::subject::Subject;
8
9/// What one policy decides about one subject.
10///
11/// Deny-only: no variant grants. An empty policy set — or a subject no
12/// policy applies to — permits everything by the absence of any [`Outcome`]
13/// at all, never by one of these variants saying so.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum Outcome {
16 /// This policy has nothing to say against the subject.
17 Allow,
18 /// This policy refuses the subject. `reason` is authored by the policy
19 /// itself, since the policy is the only thing that knows which parts of
20 /// its input are safe to surface to the caller.
21 Reject {
22 /// Why this policy refused.
23 reason: String,
24 },
25 /// This policy refuses the subject *and* judges the account unfit to keep
26 /// writing until an operator intervenes.
27 Freeze {
28 /// Why this policy froze the account.
29 reason: String,
30 },
31}
32
33/// How stale an evaluator's view of the world may be before it is
34/// unhealthy.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum Staleness {
37 /// Blocks rather than answer from a stale view. Its lag bound is its
38 /// latency bound, and it is a throughput ceiling — the right choice for
39 /// a deterministic, in-process evaluator whose answer is exact and
40 /// cheap.
41 Linearized,
42 /// Answers from what it has seen. Exceeding `max_lag` is a health
43 /// failure, routed the same way a crashed evaluator is: the writes the
44 /// tree scopes to it are refused until it catches up.
45 BestEffort {
46 /// The most this evaluator's view may lag reality before it counts
47 /// as unhealthy.
48 max_lag: Duration,
49 },
50}
51
52/// One evaluator's account of a single policy's attempt and outcome, on its
53/// declared observation surface.
54///
55/// The decision channel ([`Evaluator::evaluate`]) short-circuits once an
56/// overall verdict cannot get any more severe. This one does not: a
57/// metapolicy that freezes agents for repeatedly tripping other policies has
58/// to see every attempt and outcome on its declared surface, whether or not
59/// evaluation of the write as a whole stopped early.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub struct Observed<'a> {
62 /// The subject the attempt was against.
63 pub subject: &'a Subject<'a>,
64 /// Which policy this attempt and outcome belong to, in this crate's own
65 /// bookkeeping -- useful for a caller correlating this event with a
66 /// [`crate::evaluate::Verdict`], but not what an evaluator serving many
67 /// compiled policies uses to tell them apart; see `compiled`.
68 pub policy: PolicyId,
69 /// Which compiled artifact this attempt and outcome belong to, in the
70 /// evaluator's own naming -- see [`CompiledId`]'s doc comment. This,
71 /// not `policy`, is what a multi-policy evaluator consults.
72 pub compiled: CompiledId,
73 /// The verdict every policy that judged the subject reached together:
74 /// what the write path did with the attempt, not only what this policy
75 /// said.
76 pub outcome: &'a Outcome,
77}
78
79/// An account-lifecycle event, so an evaluator's per-account state does not
80/// outlive the account it belongs to. Not agent-only — any
81/// [`AccountKind`](crate::subject::AccountKind) can be frozen, unfrozen or
82/// deleted.
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub enum Lifecycle<'a> {
85 /// The account was frozen. Carries why, so state that reflects the
86 /// freeze reason can be told apart from state that predates it.
87 Frozen {
88 /// The frozen account.
89 account: &'a str,
90 /// Why it was frozen.
91 reason: &'a str,
92 },
93 /// The account was unfrozen by an operator. An evaluator that does not
94 /// act on this will re-trip the freeze on the next attempt, because the
95 /// state that caused it survives — see `plan/policy.md`'s "an evaluator
96 /// can never unfreeze" note.
97 Unfrozen {
98 /// The unfrozen account.
99 account: &'a str,
100 },
101 /// The account no longer exists.
102 Deleted {
103 /// The deleted account.
104 account: &'a str,
105 },
106}
107
108/// A policy-lifecycle event, parallel to [`Lifecycle`] but scoped to a
109/// compiled artifact rather than an account. Without it, an evaluator that
110/// accumulates state per compiled policy (a cache, a counter) has no way to
111/// learn that one of its artifacts no longer corresponds to anything the
112/// tree will ever route to again, and keeps it forever.
113///
114/// Delivery is not reliable, and does not need to be: a caller who never
115/// sends [`Removed`](PolicyLifecycle::Removed) for a policy that stopped
116/// existing has left an evaluator holding a stale artifact, but the tree
117/// this crate builds decides what is routed anywhere -- a stale artifact
118/// nothing routes to is a memory leak, never a correctness problem. See
119/// `plan/policy.md`'s "a missed removal is inert, not dangerous." Do not
120/// build retry or acknowledgement machinery for this channel.
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub enum PolicyLifecycle {
123 /// `compile` produced this artifact and it is now (or remains) the one
124 /// a tree may route decisions to.
125 Loaded(CompiledId),
126 /// A newer compile of the same logical policy succeeded, and this
127 /// artifact is no longer the one any tree routes to -- safe to drop.
128 /// Never sent when a newer revision *failed* to compile: `didbot-pds`'s
129 /// loader keeps routing to the last-good artifact in that case, so it
130 /// is very much still in use -- see `plan/policy.md`'s last-good-
131 /// revision behaviour.
132 Superseded(CompiledId),
133 /// The record this artifact was compiled from is gone from every
134 /// source -- deleted, or never re-observed after a source's own
135 /// failure recovered onto a view that no longer names it. Safe to
136 /// drop.
137 Removed(CompiledId),
138}
139
140/// What [`Evaluator::compile`] decided about one predicate document.
141///
142/// Three outcomes, not two, because a working compile and a working compile
143/// with something the operator should see are different enough to need
144/// different handling downstream (an operator-facing report, never a
145/// blocked policy) and collapsing them would either bury the warning or
146/// promote it to a rejection it never earned.
147#[derive(Debug, Clone, PartialEq, Eq)]
148pub enum CompileOutcome {
149 /// `predicate` compiled cleanly. `evaluate`/`observe`/`policy_lifecycle`
150 /// calls for this policy should name this [`CompiledId`] from now on.
151 Compiled(CompiledId),
152 /// `predicate` compiled, but the evaluator has something to say about
153 /// it that does not block it from being used -- e.g. "this pattern is
154 /// vulnerable to homoglyphs, normalize first." Reaches the operator;
155 /// never the caller's decision to invent on the evaluator's behalf,
156 /// since only the evaluator knows what is actually wrong with its own
157 /// compiled form.
158 CompiledWithWarnings(CompiledId, Vec<String>),
159 /// `predicate` could not be compiled at all: bad syntax, an
160 /// unsupported combination, whatever this evaluator wants to say. The
161 /// caller treats this exactly like an unrecognised `language`
162 /// -- [`crate::policy::Predicate::Unusable`],
163 /// scoped to this policy's own applicability -- except that
164 /// `didbot-pds`'s loader additionally checks whether an earlier
165 /// revision of the same policy last compiled successfully, and if so
166 /// keeps enforcing that one instead. See `plan/policy.md`'s
167 /// last-good-revision behaviour for why a rejection here must never by
168 /// itself widen or produce a blanket denial.
169 Rejected(String),
170}
171
172/// Why an evaluator could not produce an [`Outcome`].
173///
174/// A [`PolicyTree`](crate::index::PolicyTree)'s evaluation treats any
175/// `EvalError` as a refusal of the subject it was judging — fail-closed,
176/// scoped to exactly the writes that evaluator was asked about. It never
177/// escalates an error into [`Outcome::Freeze`] on its own: freezing an
178/// account is a judgement about the account, not a side effect of
179/// infrastructure being unavailable.
180#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
181pub enum EvalError {
182 /// The evaluator did not answer within its caller-enforced deadline.
183 #[error("evaluator timed out")]
184 Timeout,
185 /// The evaluator's view is more than `Staleness::BestEffort`'s `max_lag`
186 /// behind, or it could not be reached at all.
187 #[error("evaluator unavailable: {0}")]
188 Unavailable(String),
189 /// The evaluator was reached and answered, but the answer was not a
190 /// verdict — a crash, a malformed response, an internal invariant it
191 /// caught itself violating.
192 #[error("evaluator failed: {0}")]
193 Failed(String),
194}
195
196/// What judges one or more policies.
197///
198/// Implemented once per language or per stateful mechanism, not once per
199/// policy: `plan/policy.md`'s "one evaluator may serve many policies" is why
200/// [`evaluate`](Evaluator::evaluate) takes a [`PolicyId`] rather than the
201/// evaluator being scoped to a single policy at construction.
202///
203/// No method here is `async`, and none may perform I/O that assumes a
204/// runtime is available to drive it — this crate has no I/O and no async of
205/// its own. An evaluator that needs either (a network resolution, a model in
206/// the loop, a sidecar process) does so out of process, reached over
207/// whatever transport it likes, with the caller enforcing the deadline from
208/// outside — see `plan/policy.md`'s "the deadline is a security parameter".
209///
210/// # What a stateless predicate may reference
211///
212/// [`evaluate`](Evaluator::evaluate) takes a [`Subject`] and nothing else —
213/// no store handle, no resolver, no context object that could later grow
214/// one. That signature is the enforcement, not a rule an implementor is
215/// trusted to follow: a predicate physically cannot reach for a record it
216/// was not handed, because nothing reachable from its arguments can fetch
217/// one. What it *can* see, all from `Subject` itself:
218///
219/// 1. **The subject's own metadata** — its account DID, and whatever
220/// [`SubjectAttributes`](crate::subject::SubjectAttributes) names (its
221/// account kind and handle, today).
222/// 2. **The incoming write** — each [`Change::after`](crate::subject::Change::after)
223/// in the diff.
224/// 3. **The current version, if it exists** — each
225/// [`Change::before`](crate::subject::Change::before), with `None`
226/// meaning there isn't one.
227///
228/// Nothing else. Not another record, not another account's repository, not
229/// the network. An evaluator's own accumulated *state* — built from what it
230/// has observed via [`observe`](Evaluator::observe) and
231/// [`lifecycle`](Evaluator::lifecycle), and read back through `&self` — is
232/// a different thing and is exactly what makes an evaluator *stateful*
233/// rather than stateless; the boundary here is about what `evaluate` can
234/// reach *live*, at the moment it is asked, not about what an evaluator is
235/// allowed to remember.
236///
237/// This is a check on the design, not just a restriction: the epic's
238/// motivating *mention / AI opt-out* policy needs to resolve DIDs from
239/// other people's servers and cache the answers, which this signature makes
240/// impossible for a stateless predicate to do inside `evaluate` itself. That
241/// policy is therefore necessarily stateful — owning its cache as state
242/// built from what it has seen, with its own [`Staleness`] declaration —
243/// exactly where `plan/policy.md` puts it.
244/// `Send + Sync`: every evaluator is held as `Arc<dyn Evaluator>` inside a
245/// [`PolicyTree`](crate::index::PolicyTree), which a deployment's write path
246/// shares across every repository's concurrently-judged writes (see
247/// `didbot-pds::writequeue`) — an evaluator that could not cross a thread
248/// boundary could never actually be registered into one.
249pub trait Evaluator: Send + Sync {
250 /// Compile a predicate document into a live artifact this evaluator can
251 /// judge, or say why it cannot.
252 ///
253 /// Validation *is* compilation here — there is deliberately no separate
254 /// `validate`: anything a caller would learn from validating ahead of
255 /// time is exactly what a real compile attempt already answers, and a
256 /// second code path claiming to answer the same question is drift
257 /// waiting to happen. Called from the loader, never from the write
258 /// path, so a predicate's syntax is known good or known bad from the
259 /// moment it is loaded rather than surfacing at the first write.
260 ///
261 /// # The evaluator owns the artifact
262 ///
263 /// A successful outcome carries a [`CompiledId`] this evaluator itself
264 /// minted; the caller keeps only that id, never the artifact, and
265 /// round-trips the id back through `evaluate`, `observe` and
266 /// `policy_lifecycle`. See [`CompiledId`]'s doc comment for why: an
267 /// evaluator may run out of process, possibly in another language, and
268 /// a caller-owned handle to something living on the other side of that
269 /// boundary could only ever be an identifier, never the thing itself.
270 ///
271 /// # Idempotent and re-runnable
272 ///
273 /// An out-of-process evaluator restarts with nothing, so a caller must
274 /// be able to call `compile` again for a predicate it already compiled
275 /// once, with no different treatment than the first time — which is
276 /// also why a caller must keep the source document around rather than
277 /// discarding it once a `CompiledId` comes back. Calling `compile`
278 /// twice for the same predicate is expected to succeed both times, not
279 /// treated as a duplicate registration.
280 ///
281 /// # Fallible, and may time out
282 ///
283 /// This method itself has no deadline built in — this crate has no
284 /// I/O, no async, and no clock, and enforcing one against an
285 /// in-process call is cooperative rather than real. A caller compiling
286 /// against a slow or dead out-of-process evaluator enforces its own
287 /// deadline from outside (a `didbot-policy-records`-owned timeout, a
288 /// bounded channel) and treats a timeout exactly like
289 /// [`Rejected`](CompileOutcome::Rejected): this policy's applicability
290 /// is known-good, its predicate is not usable, so the caller registers
291 /// [`crate::policy::Predicate::Unusable`] scoped to it — the same
292 /// fail-closed, scoped treatment [`EvalError`] gets at decision time.
293 fn compile(&self, predicate: &serde_json::Value) -> CompileOutcome;
294
295 /// Given a subject and a compiled artifact, return an outcome, from
296 /// only what `subject` carries — see this trait's "What a stateless
297 /// predicate may reference" for the boundary the signature enforces.
298 /// `compiled` names the artifact [`compile`](Evaluator::compile)
299 /// returned for this policy, never [`PolicyId`] — an evaluator serving
300 /// many compiled policies tells them apart by `compiled` alone; it
301 /// never learns `PolicyId`, which belongs to the caller's own tree.
302 fn evaluate(&self, subject: &Subject<'_>, compiled: CompiledId) -> Result<Outcome, EvalError>;
303
304 /// Every attempt and outcome on the declared observation surface,
305 /// whether or not evaluation short-circuited. The decision channel
306 /// short-circuits; this one does not.
307 fn observe(&self, event: &Observed<'_>);
308
309 /// froze / unfroze / deleted, so per-account state does not outlive its
310 /// account.
311 fn lifecycle(&self, event: &Lifecycle<'_>);
312
313 /// loaded / superseded / removed, so per-policy state does not outlive
314 /// the artifact it belongs to — see [`PolicyLifecycle`]'s doc comment.
315 /// Defaulted to a no-op: an evaluator that holds no state per compiled
316 /// artifact (this crate's regex evaluator, most builtins) has nothing
317 /// to clean up, and should not be forced to write an empty override
318 /// just to say so.
319 fn policy_lifecycle(&self, event: PolicyLifecycle) {
320 let _ = event;
321 }
322
323 /// How stale this evaluator's view may be before it counts as unhealthy.
324 fn staleness(&self) -> Staleness;
325}