Skip to main content

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}