Skip to main content

rustev_contract/
actionability.rs

1//! Provenance-conditional checks and actionable outcomes (spec 033): the
2//! definition 2 `actionability` member and the wire types of judgment 2's
3//! actions, causes, remediations and questions.
4//!
5//! Who can act on an outcome is computed from its causes; an author may move
6//! it away from the caller, never toward it (spec 033, I-2). Nothing here is
7//! a permission: `addressed_to` and a remediation say who can act and how,
8//! and a caller-addressed outcome authorizes nothing (spec 001, 3.2).
9
10use std::collections::BTreeMap;
11
12use serde::{Deserialize, Serialize};
13
14use crate::decimal::Decimal;
15use crate::definition::{ParamSource, Predicate, ProvenanceClass, TypeDecl};
16use crate::grade::Grade;
17use crate::ids::ContentDigest;
18use crate::time::{DurationMs, Timestamp};
19
20/// The definition 2 member (spec 033, 3.1.1). Each list may be empty.
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
22#[serde(deny_unknown_fields)]
23pub struct Actionability {
24    /// Sorted, duplicate-free; only `user-supplied`,
25    /// `authenticated-app-field` and `attributed-claim` (3.2.1).
26    pub caller_controlled: Vec<ProvenanceClass>,
27    pub remediations: Vec<RemediationDecl>,
28    pub questions: Vec<QuestionDecl>,
29    pub constraint_groups: Vec<ConstraintGroup>,
30    pub checks: Vec<CheckDecl>,
31}
32
33/// Who can act on an outcome (3.5.2), ordered from most to least able to
34/// act on it themselves: the combination of parties is the greatest.
35#[derive(
36    Debug,
37    Clone,
38    Copy,
39    PartialEq,
40    Eq,
41    PartialOrd,
42    Ord,
43    Hash,
44    Serialize,
45    Deserialize,
46    schemars::JsonSchema,
47)]
48#[serde(rename_all = "snake_case")]
49pub enum Party {
50    None,
51    Caller,
52    Host,
53    Upstream,
54}
55
56impl Party {
57    /// The combination of 3.5.2: `upstream` over `host` over `caller` over
58    /// `none`.
59    pub fn combine<I: IntoIterator<Item = Party>>(parties: I) -> Party {
60        parties.into_iter().max().unwrap_or(Party::None)
61    }
62}
63
64/// A declared remediation (3.6.3).
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
66#[serde(deny_unknown_fields)]
67pub struct RemediationDecl {
68    /// `[a-z][a-z0-9_]{0,63}`; never a built-in code.
69    pub code: String,
70    /// `caller`, `host` or `upstream`.
71    #[serde(rename = "for")]
72    pub for_party: Party,
73    pub params: Vec<RemediationParam>,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
77#[serde(deny_unknown_fields)]
78pub struct RemediationParam {
79    pub name: String,
80    /// A scalar type of spec 002, 3.7.2.
81    #[serde(rename = "type")]
82    pub ty: TypeDecl,
83}
84
85/// A declared, literal question template (3.7.3).
86#[derive(
87    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, schemars::JsonSchema,
88)]
89#[serde(deny_unknown_fields)]
90pub struct QuestionDecl {
91    pub id: String,
92    /// A non-empty subset of the outcome's fields.
93    pub fields: Vec<String>,
94    /// 1 to 1024 bytes of UTF-8, carried byte-identical; no interpolation.
95    pub text: String,
96}
97
98/// A named group of checks a `relax` consequence switches off (3.3.3).
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
100#[serde(deny_unknown_fields)]
101pub struct ConstraintGroup {
102    pub name: String,
103    /// Non-empty check ids.
104    pub checks: Vec<String>,
105}
106
107/// A provenance-conditional check (3.2.2).
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
109#[serde(deny_unknown_fields)]
110pub struct CheckDecl {
111    pub id: String,
112    /// The declared input whose provenance the check branches on.
113    pub subject: String,
114    /// A `bool` predicate over inputs, `now` and literals.
115    pub test: Predicate,
116    pub on_fail: OnFail,
117}
118
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
120#[serde(deny_unknown_fields)]
121pub struct OnFail {
122    pub by_provenance: BTreeMap<ProvenanceClass, Consequence>,
123    pub default: DefaultConsequence,
124}
125
126/// A check's `default`: the string `none`, or a consequence.
127#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
128#[serde(rename_all = "snake_case")]
129pub enum DefaultConsequence {
130    None,
131    #[serde(untagged)]
132    Consequence(Consequence),
133}
134
135/// What a failed check does (3.3). `refuse`, `abstain` and `escalate` are
136/// terminal; the others are field-level.
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
138#[serde(rename_all = "snake_case", deny_unknown_fields)]
139pub enum Consequence {
140    Refuse {
141        remediation: RemediationUse,
142        #[serde(default, skip_serializing_if = "Address::is_computed")]
143        address: Address,
144    },
145    DropField {
146        notice: String,
147        #[serde(default, skip_serializing_if = "Address::is_computed")]
148        address: Address,
149    },
150    Relax {
151        group: String,
152        notice: String,
153        #[serde(default, skip_serializing_if = "Address::is_computed")]
154        address: Address,
155    },
156    DowngradeGrade {
157        /// Below `authoritative`.
158        to: Grade,
159        notice: String,
160        #[serde(default, skip_serializing_if = "Address::is_computed")]
161        address: Address,
162    },
163    Abstain,
164    Escalate {
165        reason: String,
166    },
167    ContinueWithNotice {
168        notice: String,
169        #[serde(default, skip_serializing_if = "Address::is_computed")]
170        address: Address,
171    },
172}
173
174impl Consequence {
175    /// `refuse`, `abstain` and `escalate` (3.3).
176    pub fn is_terminal(&self) -> bool {
177        matches!(
178            self,
179            Consequence::Refuse { .. } | Consequence::Abstain | Consequence::Escalate { .. }
180        )
181    }
182
183    /// The kind as `plan show` prints it.
184    pub fn kind(&self) -> &'static str {
185        match self {
186            Consequence::Refuse { .. } => "refuse",
187            Consequence::DropField { .. } => "drop_field",
188            Consequence::Relax { .. } => "relax",
189            Consequence::DowngradeGrade { .. } => "downgrade_grade",
190            Consequence::Abstain => "abstain",
191            Consequence::Escalate { .. } => "escalate",
192            Consequence::ContinueWithNotice { .. } => "continue_with_notice",
193        }
194    }
195}
196
197/// Where an outcome is addressed: computed from its causes (when omitted),
198/// or an override toward a party less able to act (3.5.3).
199#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
200#[serde(rename_all = "snake_case")]
201pub enum Address {
202    #[default]
203    Computed,
204    #[serde(untagged)]
205    Override(AddressOverride),
206}
207
208impl Address {
209    pub fn is_computed(&self) -> bool {
210        matches!(self, Address::Computed)
211    }
212}
213
214#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
215#[serde(deny_unknown_fields)]
216pub struct AddressOverride {
217    /// `host`, `upstream` or `none`; never `caller`.
218    pub to: Party,
219    /// `[a-z][a-z0-9_]{0,63}`.
220    pub reason: String,
221}
222
223/// A use of a declared or built-in remediation: its code alone, or its
224/// code with parameters (3.6.3).
225#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
226#[serde(untagged)]
227pub enum RemediationUse {
228    Code(String),
229    WithParams(RemediationCall),
230}
231
232#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
233#[serde(deny_unknown_fields)]
234pub struct RemediationCall {
235    pub code: String,
236    pub params: BTreeMap<String, ParamSource>,
237}
238
239impl RemediationUse {
240    pub fn code(&self) -> &str {
241        match self {
242            RemediationUse::Code(c) => c,
243            RemediationUse::WithParams(c) => &c.code,
244        }
245    }
246}
247
248/// Built-in remediation codes, never redeclared (3.6.2).
249pub const BUILTIN_REMEDIATIONS: &[&str] = &[
250    "provide_fields",
251    "refresh_fields",
252    "correct_fields",
253    "resolve_conflict",
254    "improve_evidence",
255    "retry",
256    "report_backend",
257    "raise_budget",
258    "provide_capability",
259    "retry_after",
260];
261
262/// A scalar or list value in a remediation's parameters: the subset of
263/// `OutValue` (spec 002) a remediation can carry, in the same wire form.
264#[derive(
265    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, schemars::JsonSchema,
266)]
267#[serde(rename_all = "snake_case", deny_unknown_fields)]
268pub enum RemediationValue {
269    Bool(bool),
270    Integer(i64),
271    Decimal(Decimal),
272    Text(String),
273    Enum(String),
274    Timestamp(Timestamp),
275    Duration(DurationMs),
276    List(Vec<RemediationValue>),
277}
278
279/// A remediation as an action carries it: `none`, or a code and its
280/// canonical parameters (3.6.1).
281#[derive(
282    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, schemars::JsonSchema,
283)]
284#[serde(rename_all = "snake_case")]
285pub enum Remediation {
286    None,
287    #[serde(untagged)]
288    Some {
289        code: String,
290        params: BTreeMap<String, RemediationValue>,
291    },
292}
293
294impl Remediation {
295    pub fn code(code: &str) -> Remediation {
296        Remediation::Some {
297            code: code.into(),
298            params: BTreeMap::new(),
299        }
300    }
301
302    pub fn with(
303        code: &str,
304        params: impl IntoIterator<Item = (String, RemediationValue)>,
305    ) -> Remediation {
306        Remediation::Some {
307            code: code.into(),
308            params: params.into_iter().collect(),
309        }
310    }
311
312    /// A `fields`-style list parameter of text values.
313    pub fn texts<I: IntoIterator<Item = String>>(items: I) -> RemediationValue {
314        RemediationValue::List(items.into_iter().map(RemediationValue::Text).collect())
315    }
316}
317
318/// The provenance an input cause records (3.5.2).
319#[derive(
320    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, schemars::JsonSchema,
321)]
322#[serde(rename_all = "snake_case", deny_unknown_fields)]
323pub enum CauseProvenance {
324    /// An accepted entry existed (including a dropped or invalid one).
325    Observed { class: ProvenanceClass },
326    /// No entry did: the classes the input accepts.
327    Accepted { classes: Vec<ProvenanceClass> },
328}
329
330/// Who registered a backend (3.9): `upstream` unless the host implements it.
331#[derive(
332    Debug,
333    Clone,
334    Copy,
335    Default,
336    PartialEq,
337    Eq,
338    PartialOrd,
339    Ord,
340    Hash,
341    Serialize,
342    Deserialize,
343    schemars::JsonSchema,
344)]
345#[serde(rename_all = "snake_case")]
346pub enum Origin {
347    #[default]
348    Upstream,
349    Host,
350}
351
352/// One cause of an outcome or notice (3.5.2).
353#[derive(
354    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, schemars::JsonSchema,
355)]
356#[serde(rename_all = "snake_case", deny_unknown_fields)]
357pub enum Cause {
358    Input {
359        field: String,
360        provenance: CauseProvenance,
361    },
362    Backend {
363        step: String,
364        origin: Origin,
365    },
366    Budget {
367        resource: String,
368    },
369    Deadline,
370    Capability {
371        name: String,
372    },
373    /// `check:<id>`, `rule:<id>` or `handler:<n>`.
374    Policy {
375        site: String,
376    },
377    #[serde(untagged)]
378    CapabilityStep {
379        capability: CapabilityStep,
380    },
381}
382
383/// A fallback field identifies the extraction step (spec 038, 3.12.3).
384#[derive(
385    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, schemars::JsonSchema,
386)]
387#[serde(deny_unknown_fields)]
388pub struct CapabilityStep {
389    pub step: String,
390}
391
392/// How an override came about (3.5.2).
393#[derive(
394    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, schemars::JsonSchema,
395)]
396#[serde(rename_all = "snake_case")]
397pub enum OverrideBasis {
398    Declared,
399    Remediation,
400}
401
402/// `none`, or the party an author moved the outcome to and why.
403#[derive(
404    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, schemars::JsonSchema,
405)]
406#[serde(rename_all = "snake_case")]
407pub enum Override {
408    None,
409    #[serde(untagged)]
410    Applied {
411        to: Party,
412        basis: OverrideBasis,
413        reason: String,
414    },
415}
416
417/// Who can act on an outcome or notice, how, why, and whether retrying the
418/// same request can succeed (3.5.2). Every member is always written.
419#[derive(
420    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, schemars::JsonSchema,
421)]
422#[serde(deny_unknown_fields)]
423pub struct Action {
424    pub addressed_to: Party,
425    pub computed: Party,
426    #[serde(rename = "override")]
427    pub override_: Override,
428    pub remediation: Remediation,
429    /// Duplicate-free, sorted by each cause's canonical JSON bytes.
430    pub caused_by: Vec<Cause>,
431    pub transient: bool,
432}
433
434/// One refusing check of a `refused` outcome (3.5.1).
435#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
436#[serde(deny_unknown_fields)]
437pub struct Refusal {
438    pub check: String,
439    pub subject: String,
440    pub action: Action,
441}
442
443/// A question as a `needs_input` outcome carries it: the declared template,
444/// byte-identical (3.7.3).
445pub type Question = QuestionDecl;
446
447/// The `needs_input` outcome (3.7).
448#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
449#[serde(deny_unknown_fields)]
450pub struct NeedsInput {
451    pub fields: Vec<String>,
452    pub questions: Vec<Question>,
453    /// `tagged_digest("rustev.continuation/1", ...)` (3.7.4).
454    pub continuation: ContentDigest,
455    pub action: Action,
456}
457
458/// The canonical inputs of a continuation digest (3.7.4), in field order.
459#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
460pub struct ContinuationInputs<'a> {
461    pub plan_id: &'a str,
462    pub snapshot_id: &'a str,
463    pub evaluation_time_ms: i64,
464    pub site: &'a str,
465    pub fields: &'a [String],
466    pub questions: &'a [String],
467}
468
469/// The tag of a continuation digest.
470pub const CONTINUATION_TAG: &str = "rustev.continuation/1";
471
472/// A check's status in evidence 2 (3.4.8).
473#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
474#[serde(rename_all = "snake_case", deny_unknown_fields)]
475pub enum CheckStatus {
476    Passed,
477    Failed {
478        class: ProvenanceClass,
479        /// The consequence kind applied.
480        consequence: String,
481    },
482    NotEvaluated {
483        reason: crate::judgment::Unresolved,
484    },
485    Unevaluable {
486        reason: crate::judgment::Unresolved,
487    },
488    Relaxed {
489        by: String,
490    },
491}
492
493/// One check's evidence record (3.4.8).
494#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
495#[serde(deny_unknown_fields)]
496pub struct CheckRecord {
497    pub check: String,
498    pub subject: String,
499    pub status: CheckStatus,
500}
501
502/// The plan 3 member: the static causes and party of every check branch
503/// and policy site (3.8.3).
504#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
505#[serde(deny_unknown_fields)]
506pub struct PlanActionability {
507    pub checks: Vec<StaticCheck>,
508    pub sites: Vec<StaticSite>,
509}
510
511#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
512#[serde(deny_unknown_fields)]
513pub struct StaticCheck {
514    pub check: String,
515    pub subject: String,
516    /// One per `by_provenance` key, then the `default` (when not `none`)
517    /// with every class it covers.
518    pub branches: Vec<StaticBranch>,
519}
520
521#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
522#[serde(deny_unknown_fields)]
523pub struct StaticBranch {
524    pub classes: Vec<ProvenanceClass>,
525    pub consequence: String,
526    pub causes: Vec<Cause>,
527    pub party: Party,
528}
529
530/// A policy site that can produce an action (`rule:<id>`, `handler:<n>`).
531#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
532#[serde(deny_unknown_fields)]
533pub struct StaticSite {
534    pub site: String,
535    pub causes: Vec<Cause>,
536    pub party: Party,
537}
538
539/// The classes a host may declare caller-controlled (3.2.1).
540pub const CALLER_CONTROLLABLE: [ProvenanceClass; 3] = [
541    ProvenanceClass::UserSupplied,
542    ProvenanceClass::AuthenticatedAppField,
543    ProvenanceClass::AttributedClaim,
544];
545
546/// A code of `[a-z][a-z0-9_]{0,63}` (3.3.1, 3.5.3, 3.6.1).
547pub fn is_code(s: &str) -> bool {
548    let b = s.as_bytes();
549    !b.is_empty()
550        && b.len() <= 64
551        && b[0].is_ascii_lowercase()
552        && b.iter()
553            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || *c == b'_')
554}