Skip to main content

rustev_core/
actionable.rs

1//! Provenance-conditional checks and actionable outcomes (spec 033).
2//!
3//! Compilation (3.8): the structural rules of phase (b), the types of phase
4//! (e), the possible reasons checks add for phase (h), and phase (h2), which
5//! checks exhaustiveness and caller addressing and builds plan 3's static
6//! table of causes and parties. Evaluation follows in later increments; a
7//! definition with `actionability` is compiled but not yet evaluated.
8
9use std::collections::{BTreeMap, BTreeSet};
10
11use rustev_contract::actionability::{
12    Address, BUILTIN_REMEDIATIONS, CALLER_CONTROLLABLE, Cause, CauseProvenance, Consequence,
13    DefaultConsequence, Origin, Party, PlanActionability, RemediationUse, StaticBranch,
14    StaticCheck, StaticSite, is_code,
15};
16use rustev_contract::definition::{
17    Cond, Definition, HandlerAction, InputDecl, OutcomeDecl, ParamSource, ProvenanceClass,
18    ReasonKind, ReasonSet, TypeDecl,
19};
20use rustev_contract::grade::Grade;
21
22use crate::compile::{Category as C, Refusal};
23use crate::expr::{Ref, TyEnv, predicate_refs, type_predicate};
24use crate::value::Ty;
25
26fn refusal(category: C, subject: impl Into<String>, detail: impl Into<String>) -> Refusal {
27    Refusal {
28        category,
29        subject: subject.into(),
30        detail: detail.into(),
31        shortfalls: vec![],
32    }
33}
34
35fn invalid(subject: impl Into<String>, detail: impl Into<String>) -> Refusal {
36    refusal(C::InvalidDefinition, subject, detail)
37}
38
39/// The first construct of spec 033 in `def`, with its subject, in the order
40/// the definition declares them: the `actionability` member, then policy
41/// handlers, rules and adjustments.
42pub(crate) fn first_construct(def: &Definition) -> Option<(String, &'static str)> {
43    if def.actionability.is_some() {
44        return Some(("definition".into(), "an actionability declaration"));
45    }
46    let policy = &def.policy;
47    for (n, h) in policy.on_unresolved.iter().enumerate() {
48        match h.action {
49            HandlerAction::PropagateWith { .. } => {
50                return Some((handler_subject(n), "a propagate_with handler"));
51            }
52            HandlerAction::NeedsInput { .. } => {
53                return Some((handler_subject(n), "a needs_input handler"));
54            }
55            _ => {}
56        }
57    }
58    for r in &policy.rules {
59        let subject = format!("policy:rule:{}", r.id);
60        if cond_uses(&r.when) {
61            return Some((subject, "a provenance_within condition"));
62        }
63        if matches!(r.then, OutcomeDecl::NeedsInput { .. }) {
64            return Some((subject, "a needs_input outcome"));
65        }
66    }
67    // An adjustment has a condition and no outcome of its own (spec 002,
68    // 3.11), so only its condition can hold a construct of this spec.
69    for a in &policy.adjustments {
70        if cond_uses(&a.when) {
71            return Some((
72                format!("policy:adjustment:{}", a.id),
73                "a provenance_within condition",
74            ));
75        }
76    }
77    None
78}
79
80/// A handler's refusal subject, as spec 002's policy structure names it.
81pub(crate) fn handler_subject(n: usize) -> String {
82    format!("policy:on_unresolved[{n}]")
83}
84
85/// True when the condition contains `provenance_within`.
86pub(crate) fn cond_uses(c: &Cond) -> bool {
87    match c {
88        Cond::ProvenanceWithin { .. } => true,
89        Cond::All(cs) | Cond::Any(cs) => cs.iter().any(cond_uses),
90        Cond::Not(c) => cond_uses(c),
91        _ => false,
92    }
93}
94
95/// The party of one provenance class (3.5.2).
96pub(crate) fn class_party(class: ProvenanceClass, caller_controlled: &[ProvenanceClass]) -> Party {
97    match class {
98        c if caller_controlled.contains(&c) => Party::Caller,
99        ProvenanceClass::ThirdParty | ProvenanceClass::SystemOfRecord => Party::Upstream,
100        _ => Party::Host,
101    }
102}
103
104/// The party of one cause (3.5.2).
105pub(crate) fn cause_party(cause: &Cause, caller_controlled: &[ProvenanceClass]) -> Party {
106    match cause {
107        Cause::Input { provenance, .. } => match provenance {
108            CauseProvenance::Observed { class } => class_party(*class, caller_controlled),
109            CauseProvenance::Accepted { classes } => {
110                Party::combine(classes.iter().map(|c| class_party(*c, caller_controlled)))
111            }
112        },
113        Cause::Backend { origin, .. } => match origin {
114            Origin::Upstream => Party::Upstream,
115            Origin::Host => Party::Host,
116        },
117        Cause::Budget { .. }
118        | Cause::Deadline
119        | Cause::Capability { .. }
120        | Cause::CapabilityStep { .. } => Party::Host,
121        Cause::Policy { .. } => Party::None,
122    }
123}
124
125/// The computed party of a list of causes under the declared
126/// caller-controlled classes (3.5.2): each cause's party, combined.
127pub fn computed_party(causes: &[Cause], caller_controlled: &[ProvenanceClass]) -> Party {
128    Party::combine(causes.iter().map(|c| cause_party(c, caller_controlled)))
129}
130
131/// Duplicate-free causes sorted by their canonical JSON bytes (3.5.2).
132pub(crate) fn sort_causes(causes: Vec<Cause>) -> Vec<Cause> {
133    let mut keyed: Vec<(Vec<u8>, Cause)> = causes
134        .into_iter()
135        .map(|c| {
136            let bytes = rustev_contract::canonical::record_canonical_bytes(&c).unwrap_or_default();
137            (bytes, c)
138        })
139        .collect();
140    keyed.sort_by(|a, b| a.0.cmp(&b.0));
141    keyed.dedup_by(|a, b| a.0 == b.0);
142    keyed.into_iter().map(|(_, c)| c).collect()
143}
144
145fn accepted(field: &str, classes: Vec<ProvenanceClass>) -> Cause {
146    let mut classes = classes;
147    classes.sort();
148    classes.dedup();
149    Cause::Input {
150        field: field.into(),
151        provenance: CauseProvenance::Accepted { classes },
152    }
153}
154
155/// Parameter names of the built-in remediations an author may use (3.6.2).
156fn builtin_params(code: &str) -> Option<&'static [&'static str]> {
157    Some(match code {
158        "provide_fields" | "refresh_fields" | "correct_fields" | "improve_evidence" => &["fields"],
159        "resolve_conflict" => &["facts"],
160        "retry" => &[],
161        "report_backend" => &["step"],
162        "raise_budget" => &["resource"],
163        "provide_capability" => &["name"],
164        "retry_after" => &["ms"],
165        _ => return None,
166    })
167}
168
169fn scalar(t: &TypeDecl) -> bool {
170    matches!(
171        t,
172        TypeDecl::Bool
173            | TypeDecl::Integer
174            | TypeDecl::Decimal
175            | TypeDecl::Text { .. }
176            | TypeDecl::Timestamp
177            | TypeDecl::Enum { .. }
178    )
179}
180
181struct Names<'a> {
182    def: &'a Definition,
183    inputs: &'a BTreeMap<String, (Ty, InputDecl)>,
184}
185
186impl Names<'_> {
187    fn remediation(&self, subject: &str, r: &RemediationUse) -> Result<(), Refusal> {
188        let given: BTreeSet<&str> = match r {
189            RemediationUse::Code(_) => BTreeSet::new(),
190            RemediationUse::WithParams(c) => c.params.keys().map(String::as_str).collect(),
191        };
192        let code = r.code();
193        let wanted: BTreeSet<&str> = match builtin_params(code) {
194            Some(p) => p.iter().copied().collect(),
195            None => match self
196                .def
197                .actionability
198                .as_ref()
199                .and_then(|a| a.remediations.iter().find(|d| d.code == code))
200            {
201                Some(d) => d.params.iter().map(|p| p.name.as_str()).collect(),
202                None => {
203                    return Err(invalid(subject, format!("unknown remediation {code:?}")));
204                }
205            },
206        };
207        if given != wanted {
208            return Err(invalid(
209                subject,
210                format!("remediation {code:?} takes exactly the parameters {wanted:?}"),
211            ));
212        }
213        if let RemediationUse::WithParams(c) = r {
214            for (name, p) in &c.params {
215                if let ParamSource::Ref(x) = p {
216                    match Ref::parse(x) {
217                        Ok(Ref::Input(n, _)) if self.inputs.contains_key(&n) => {}
218                        _ => {
219                            return Err(invalid(
220                                subject,
221                                format!("parameter {name:?} reads {x:?}, not a declared input"),
222                            ));
223                        }
224                    }
225                }
226            }
227        }
228        Ok(())
229    }
230
231    fn address(&self, subject: &str, a: &Address) -> Result<(), Refusal> {
232        if let Address::Override(o) = a {
233            if o.to == Party::Caller {
234                return Err(refusal(
235                    C::CallerCannotAct,
236                    subject,
237                    "override-to-caller: an override moves an outcome away from the caller",
238                ));
239            }
240            if !is_code(&o.reason) {
241                return Err(invalid(
242                    subject,
243                    format!("override reason {:?} is not a code", o.reason),
244                ));
245            }
246        }
247        Ok(())
248    }
249
250    fn code(&self, subject: &str, what: &str, c: &str) -> Result<(), Refusal> {
251        if is_code(c) {
252            Ok(())
253        } else {
254            Err(invalid(subject, format!("{what} {c:?} is not a code")))
255        }
256    }
257
258    fn consequence(&self, subject: &str, check: &str, k: &Consequence) -> Result<(), Refusal> {
259        let a = self.def.actionability.as_ref();
260        match k {
261            Consequence::Refuse {
262                remediation,
263                address,
264            } => {
265                self.remediation(subject, remediation)?;
266                self.address(subject, address)
267            }
268            Consequence::DropField { notice, address }
269            | Consequence::ContinueWithNotice { notice, address } => {
270                self.code(subject, "notice", notice)?;
271                self.address(subject, address)
272            }
273            Consequence::Relax {
274                group,
275                notice,
276                address,
277            } => {
278                if !a.is_some_and(|a| a.constraint_groups.iter().any(|g| &g.name == group)) {
279                    return Err(invalid(
280                        subject,
281                        format!("unknown constraint group {group:?}"),
282                    ));
283                }
284                self.code(subject, "notice", notice)?;
285                self.address(subject, address)
286            }
287            Consequence::DowngradeGrade {
288                to,
289                notice,
290                address,
291            } => {
292                let subject_input = a
293                    .and_then(|a| a.checks.iter().find(|c| c.id == check))
294                    .and_then(|c| self.inputs.get(&c.subject));
295                if !subject_input.is_some_and(|(_, d)| d.evidence.is_some()) {
296                    return Err(invalid(
297                        subject,
298                        "ungraded-subject: downgrade_grade needs a graded subject",
299                    ));
300                }
301                if *to >= Grade::Authoritative {
302                    return Err(invalid(
303                        subject,
304                        "downgrade_grade must name a grade below authoritative",
305                    ));
306                }
307                self.code(subject, "notice", notice)?;
308                self.address(subject, address)
309            }
310            Consequence::Abstain => Ok(()),
311            Consequence::Escalate { reason } => self.code(subject, "escalation reason", reason),
312        }
313    }
314
315    fn needs_input(
316        &self,
317        subject: &str,
318        fields: &[String],
319        questions: &[String],
320    ) -> Result<(), Refusal> {
321        let set: BTreeSet<&String> = fields.iter().collect();
322        if fields.is_empty() || set.len() != fields.len() {
323            return Err(invalid(subject, "needs_input names unique fields"));
324        }
325        for f in fields {
326            if !self.inputs.contains_key(f) {
327                return Err(invalid(subject, format!("unknown input {f:?}")));
328            }
329        }
330        let declared = self
331            .def
332            .actionability
333            .as_ref()
334            .map(|a| a.questions.as_slice())
335            .unwrap_or_default();
336        let qset: BTreeSet<&String> = questions.iter().collect();
337        if qset.len() != questions.len() {
338            return Err(invalid(subject, "needs_input names each question once"));
339        }
340        for q in questions {
341            let Some(t) = declared.iter().find(|t| &t.id == q) else {
342                return Err(invalid(subject, format!("unknown question {q:?}")));
343            };
344            if !t.fields.iter().all(|f| set.contains(f)) {
345                return Err(invalid(
346                    subject,
347                    format!("question {q:?} asks for a field the outcome does not name"),
348                ));
349            }
350        }
351        Ok(())
352    }
353}
354
355/// Phase (b): every structural rule of 3.1 to 3.7, and category 17's phase
356/// (b) cases (3.8.2), in declaration order after the existing structure.
357pub(crate) fn structure(
358    def: &Definition,
359    inputs: &BTreeMap<String, (Ty, InputDecl)>,
360    taken_ids: &BTreeSet<String>,
361) -> Result<(), Refusal> {
362    let names = Names { def, inputs };
363    if let Some(screening) = &def.screening {
364        for entry in &screening.entries {
365            names.address(&format!("screening:{}", entry.id), &entry.address)?;
366        }
367    }
368    let policy = &def.policy;
369    for (n, h) in policy.on_unresolved.iter().enumerate() {
370        let subject = handler_subject(n);
371        match &h.action {
372            HandlerAction::PropagateWith {
373                remediation,
374                address,
375            } => {
376                if let Some(r) = remediation {
377                    names.remediation(&subject, r)?;
378                }
379                names.address(&subject, address)?;
380            }
381            HandlerAction::NeedsInput { fields, questions } => {
382                names.needs_input(&subject, fields, questions.allowed())?
383            }
384            _ => {}
385        }
386    }
387    for r in &policy.rules {
388        let subject = format!("policy:rule:{}", r.id);
389        provenance_conds(&r.when, &subject, inputs)?;
390        if let OutcomeDecl::NeedsInput { fields, questions } = &r.then {
391            names.needs_input(&subject, fields, questions.allowed())?;
392        }
393    }
394    for a in &policy.adjustments {
395        provenance_conds(&a.when, &format!("policy:adjustment:{}", a.id), inputs)?;
396    }
397    let Some(a) = &def.actionability else {
398        return Ok(());
399    };
400    for c in &a.caller_controlled {
401        if !CALLER_CONTROLLABLE.contains(c) {
402            return Err(refusal(
403                C::CallerCannotAct,
404                "definition",
405                format!(
406                    "not-caller-controllable: {} cannot be caller-controlled",
407                    class_name(*c)
408                ),
409            ));
410        }
411    }
412    if a.caller_controlled.windows(2).any(|w| w[0] >= w[1]) {
413        return Err(invalid(
414            "definition",
415            "caller_controlled must be sorted and duplicate-free",
416        ));
417    }
418    let mut codes = BTreeSet::new();
419    for r in &a.remediations {
420        let subject = format!("remediation:{}", r.code);
421        if !is_code(&r.code)
422            || BUILTIN_REMEDIATIONS.contains(&r.code.as_str())
423            || !codes.insert(&r.code)
424        {
425            return Err(invalid(
426                subject,
427                "a declared remediation code is a unique code that is not built in",
428            ));
429        }
430        if r.for_party == Party::None {
431            return Err(invalid(
432                subject,
433                "a remediation is for caller, host or upstream",
434            ));
435        }
436        let mut params = BTreeSet::new();
437        for p in &r.params {
438            if !is_code(&p.name) || !params.insert(&p.name) || !scalar(&p.ty) {
439                return Err(invalid(
440                    subject,
441                    format!(
442                        "parameter {:?} needs a unique name and a scalar type",
443                        p.name
444                    ),
445                ));
446            }
447        }
448    }
449    let mut qids = BTreeSet::new();
450    for q in &a.questions {
451        let subject = format!("question:{}", q.id);
452        if !valid_name(&q.id) || !qids.insert(&q.id) {
453            return Err(invalid(subject, "question ids are unique and valid"));
454        }
455        if q.fields.is_empty() || q.fields.iter().any(|f| !inputs.contains_key(f)) {
456            return Err(invalid(subject, "a question asks for declared inputs"));
457        }
458        if q.text.is_empty() || q.text.len() > 1024 {
459            return Err(invalid(subject, "a question's text is 1 to 1024 bytes"));
460        }
461    }
462    let check_ids: Vec<&String> = a.checks.iter().map(|c| &c.id).collect();
463    let mut groups = BTreeSet::new();
464    for g in &a.constraint_groups {
465        let subject = format!("group:{}", g.name);
466        if !valid_name(&g.name) || !groups.insert(&g.name) {
467            return Err(invalid(subject, "group names are unique and valid"));
468        }
469        if g.checks.is_empty() || g.checks.iter().any(|c| !check_ids.contains(&c)) {
470            return Err(invalid(
471                subject,
472                "a group names one or more declared checks",
473            ));
474        }
475    }
476    let mut seen = BTreeSet::new();
477    for (i, c) in a.checks.iter().enumerate() {
478        let subject = format!("check:{}", c.id);
479        if !valid_name(&c.id) || taken_ids.contains(&c.id) || !seen.insert(&c.id) {
480            return Err(invalid(
481                subject,
482                "check ids are unique among checks, steps and policy items",
483            ));
484        }
485        let Some((_, decl)) = inputs.get(&c.subject) else {
486            return Err(invalid(subject, format!("unknown subject {:?}", c.subject)));
487        };
488        for class in c.on_fail.by_provenance.keys() {
489            if !decl.provenance.contains(class) {
490                return Err(invalid(
491                    subject,
492                    format!(
493                        "unreachable-branch: {} is not accepted by {}",
494                        class_name(*class),
495                        c.subject
496                    ),
497                ));
498            }
499        }
500        let branches = c
501            .on_fail
502            .by_provenance
503            .values()
504            .chain(match &c.on_fail.default {
505                DefaultConsequence::None => None,
506                DefaultConsequence::Consequence(k) => Some(k),
507            });
508        for k in branches {
509            names.consequence(&subject, &c.id, k)?;
510            if let Consequence::Relax { group, .. } = k {
511                let g = a.constraint_groups.iter().find(|g| &g.name == group);
512                let after = |id: &String| {
513                    a.checks
514                        .iter()
515                        .position(|x| &x.id == id)
516                        .is_some_and(|p| p > i)
517                };
518                if !g.is_some_and(|g| g.checks.iter().all(after)) {
519                    return Err(invalid(
520                        subject,
521                        format!(
522                            "relax-order: group {group:?} names a check not declared after {}",
523                            c.id
524                        ),
525                    ));
526                }
527            }
528        }
529    }
530    Ok(())
531}
532
533fn provenance_conds(
534    c: &Cond,
535    subject: &str,
536    inputs: &BTreeMap<String, (Ty, InputDecl)>,
537) -> Result<(), Refusal> {
538    match c {
539        Cond::ProvenanceWithin { reference, classes } => {
540            if classes.is_empty() || classes.windows(2).any(|w| w[0] >= w[1]) {
541                return Err(invalid(
542                    subject,
543                    "provenance_within classes are non-empty, sorted and duplicate-free",
544                ));
545            }
546            match Ref::parse(reference) {
547                Ok(Ref::Input(n, p)) if p.is_empty() && inputs.contains_key(&n) => Ok(()),
548                Ok(Ref::Step(_)) => Ok(()),
549                _ => Err(invalid(
550                    subject,
551                    format!("provenance_within reads {reference:?}, not an input or step"),
552                )),
553            }
554        }
555        Cond::All(cs) | Cond::Any(cs) => cs
556            .iter()
557            .try_for_each(|c| provenance_conds(c, subject, inputs)),
558        Cond::Not(c) => provenance_conds(c, subject, inputs),
559        _ => Ok(()),
560    }
561}
562
563fn valid_name(s: &str) -> bool {
564    !s.is_empty() && !s.contains(':') && !s.chars().any(char::is_whitespace)
565}
566
567pub(crate) fn class_name(c: ProvenanceClass) -> String {
568    serde_json::to_value(c)
569        .ok()
570        .and_then(|v| v.as_str().map(str::to_string))
571        .unwrap_or_default()
572}
573
574/// Phase (e): each check's test is a `bool` predicate over inputs, `now` and
575/// literals (it reads no step), and each remediation parameter has its
576/// declared type (category 3).
577pub(crate) fn types(
578    def: &Definition,
579    input_tys: &BTreeMap<String, Ty>,
580    v2: bool,
581) -> Result<(), Refusal> {
582    let Some(a) = &def.actionability else {
583        return Ok(());
584    };
585    let no_steps = BTreeMap::new();
586    let env = TyEnv {
587        inputs: input_tys,
588        steps: &no_steps,
589        item: None,
590        v2,
591    };
592    for c in &a.checks {
593        let subject = format!("check:{}", c.id);
594        // Before typing, whose empty step map would only say "unknown step".
595        let mut refs = Vec::new();
596        predicate_refs(&c.test, &mut refs);
597        if let Some(r) = refs.iter().find(|r| r.starts_with("step:")) {
598            return Err(refusal(
599                C::KindMismatch,
600                subject,
601                format!("a check reads inputs, now and literals only, not {r:?}"),
602            ));
603        }
604        type_predicate(&c.test, &env).map_err(|e| refusal(C::KindMismatch, &subject, e))?;
605        let branches = c
606            .on_fail
607            .by_provenance
608            .values()
609            .chain(match &c.on_fail.default {
610                DefaultConsequence::None => None,
611                DefaultConsequence::Consequence(k) => Some(k),
612            });
613        for k in branches {
614            if let Consequence::Refuse {
615                remediation: RemediationUse::WithParams(call),
616                ..
617            } = k
618            {
619                remediation_types(&subject, &call.code, &call.params, a, &env)?;
620            }
621        }
622    }
623    for (n, h) in def.policy.on_unresolved.iter().enumerate() {
624        if let HandlerAction::PropagateWith {
625            remediation: Some(RemediationUse::WithParams(call)),
626            ..
627        } = &h.action
628        {
629            remediation_types(&handler_subject(n), &call.code, &call.params, a, &env)?;
630        }
631    }
632    Ok(())
633}
634
635fn remediation_types(
636    subject: &str,
637    code: &str,
638    params: &BTreeMap<String, ParamSource>,
639    a: &rustev_contract::actionability::Actionability,
640    env: &TyEnv<'_>,
641) -> Result<(), Refusal> {
642    let declared: BTreeMap<&str, Ty> = match a.remediations.iter().find(|d| d.code == code) {
643        Some(d) => d
644            .params
645            .iter()
646            .map(|p| (p.name.as_str(), Ty::from_decl(&p.ty)))
647            .collect(),
648        None => builtin_params(code)
649            .unwrap_or_default()
650            .iter()
651            .map(|n| {
652                let ty = match *n {
653                    "ms" => Ty::Integer,
654                    "fields" | "facts" => Ty::List {
655                        item: Box::new(Ty::Text {
656                            max_bytes: u64::MAX,
657                        }),
658                        max: u64::MAX,
659                    },
660                    _ => Ty::Text {
661                        max_bytes: u64::MAX,
662                    },
663                };
664                (*n, ty)
665            })
666            .collect(),
667    };
668    for (name, p) in params {
669        let Some(want) = declared.get(name.as_str()) else {
670            continue;
671        };
672        let got = match p {
673            ParamSource::Lit(l) => crate::value::Value::literal_ty(l),
674            ParamSource::Ref(r) => {
675                crate::expr::ref_ty(r, env).map_err(|e| refusal(C::KindMismatch, subject, e))?
676            }
677        };
678        if !same_kind(&got, want) {
679            return Err(refusal(
680                C::KindMismatch,
681                subject,
682                format!("parameter {name:?} is {got:?}, not {want:?}"),
683            ));
684        }
685    }
686    Ok(())
687}
688
689/// Types agree in kind; text and list bounds do not matter for a parameter.
690fn same_kind(a: &Ty, b: &Ty) -> bool {
691    match (a, b) {
692        (Ty::Text { .. }, Ty::Text { .. }) => true,
693        (Ty::List { item: x, .. }, Ty::List { item: y, .. }) => same_kind(x, y),
694        (Ty::Enum(_), Ty::Enum(_)) => true,
695        _ => std::mem::discriminant(a) == std::mem::discriminant(b),
696    }
697}
698
699/// Phase (h): what checks add to the reasons a subject can have, applied to
700/// the input reasons before any step's reasons are derived from them. A check
701/// whose test cannot be evaluated makes its subject unresolved with the
702/// reason of the read that failed (3.4.3); a downgrade can leave a graded
703/// read below its requirement (spec 032's `insufficient_grade`).
704pub(crate) fn extend_reasons(
705    def: &Definition,
706    reasons: &mut BTreeMap<String, BTreeSet<ReasonKind>>,
707) {
708    let Some(a) = &def.actionability else {
709        return;
710    };
711    // To a fixpoint: a check's subject can feed another check's test in any
712    // declaration order, and each pass only adds reasons.
713    loop {
714        let before = reasons.clone();
715        extend_once(a, reasons);
716        if *reasons == before {
717            break;
718        }
719    }
720}
721
722fn extend_once(
723    a: &rustev_contract::actionability::Actionability,
724    reasons: &mut BTreeMap<String, BTreeSet<ReasonKind>>,
725) {
726    for c in &a.checks {
727        let subject = format!("input:{}", c.subject);
728        let mut refs = Vec::new();
729        predicate_refs(&c.test, &mut refs);
730        let mut add: BTreeSet<ReasonKind> = BTreeSet::new();
731        for r in refs.iter().filter(|r| r.starts_with("input:")) {
732            add.extend(
733                reasons
734                    .get(&crate::expr::base_ref(r))
735                    .cloned()
736                    .unwrap_or_default(),
737            );
738        }
739        // A test that faults (overflow, invalid comparison) is invalid input.
740        add.insert(ReasonKind::InvalidInput);
741        let downgrades = c
742            .on_fail
743            .by_provenance
744            .values()
745            .chain(match &c.on_fail.default {
746                DefaultConsequence::None => None,
747                DefaultConsequence::Consequence(k) => Some(k),
748            })
749            .any(|k| matches!(k, Consequence::DowngradeGrade { .. }));
750        if downgrades {
751            add.insert(ReasonKind::InsufficientGrade);
752        }
753        reasons.entry(subject).or_default().extend(add);
754    }
755}
756
757/// Every input a step reads, transitively, in name order.
758fn lineage(step: &str, deps: &BTreeMap<String, Vec<String>>) -> Vec<String> {
759    let mut out = BTreeSet::new();
760    let mut stack = vec![step.to_string()];
761    let mut seen = BTreeSet::new();
762    while let Some(s) = stack.pop() {
763        if !seen.insert(s.clone()) {
764            continue;
765        }
766        for d in deps.get(&s).into_iter().flatten() {
767            if let Some(n) = d.strip_prefix("input:") {
768                out.insert(n.split('/').next().unwrap_or(n).to_string());
769            } else if let Some(x) = d.strip_prefix("step:") {
770                stack.push(crate::expr::split_step(x).0);
771            }
772        }
773    }
774    out.into_iter().collect()
775}
776
777/// The static causes of `reason` on `target` (3.5.6, with accepted classes):
778/// a backend's origin is not known before registration, so its static
779/// cause is the stronger, `upstream`.
780fn static_reason_causes(
781    reason: ReasonKind,
782    target: &str,
783    inputs: &BTreeMap<String, (Ty, InputDecl)>,
784    deps: &BTreeMap<String, Vec<String>>,
785    site: &str,
786) -> Vec<Cause> {
787    use ReasonKind as K;
788    let fields = || -> Vec<String> {
789        match target.strip_prefix("input:") {
790            Some(n) => vec![n.to_string()],
791            None => lineage(target.strip_prefix("step:").unwrap_or(target), deps),
792        }
793    };
794    let input_causes = || {
795        fields()
796            .into_iter()
797            .filter_map(|f| {
798                inputs
799                    .get(&f)
800                    .map(|(_, d)| accepted(&f, d.provenance.clone()))
801            })
802            .collect()
803    };
804    match reason {
805        K::MissingEvidence
806        | K::StaleEvidence
807        | K::InvalidInput
808        | K::Conflict
809        | K::InsufficientGrade => input_causes(),
810        // The static table is a plan fact: a backend's origin is a
811        // registration fact (spec 033 3.9.1) the plan cannot see, so every
812        // static backend cause carries the default `upstream`. The dynamic
813        // path (evaluate.rs) replaces it with the registered origin.
814        K::BackendUnavailable | K::InvalidBackendOutput => vec![Cause::Backend {
815            step: target.strip_prefix("step:").unwrap_or(target).to_string(),
816            origin: Origin::Upstream,
817        }],
818        K::BudgetExhausted => vec![Cause::Budget {
819            resource: "semantic_requests".into(),
820        }],
821        K::DeadlineExceeded => vec![Cause::Deadline],
822        K::Unsupported => vec![Cause::Capability {
823            name: target.strip_prefix("step:").unwrap_or(target).to_string(),
824        }],
825        K::ScreenUnavailable => deps
826            .get(target.strip_prefix("step:").unwrap_or(target))
827            .into_iter()
828            .flatten()
829            .filter_map(|r| {
830                r.strip_prefix("step:screen:").map(|_| Cause::Backend {
831                    step: r.trim_start_matches("step:").into(),
832                    // Static causes are plan facts. A screener's origin is a
833                    // registration fact (spec 033 3.9.1) the plan cannot
834                    // see, so the static table carries the default
835                    // `upstream`, exactly as every static backend cause does;
836                    // evaluation (evaluate.rs) replaces it with the
837                    // screener's registered origin for the actual outage.
838                    origin: Origin::Upstream,
839                })
840            })
841            .collect(),
842        K::Abstained | K::Skipped => vec![Cause::Policy { site: site.into() }],
843    }
844}
845
846/// Phase (h2): exhaustiveness (category 16) and caller addressing
847/// (category 17), then the static table of plan 3 (3.8.3).
848pub(crate) fn phase_h2(
849    def: &Definition,
850    inputs: &BTreeMap<String, (Ty, InputDecl)>,
851    deps: &BTreeMap<String, Vec<String>>,
852    reasons: &BTreeMap<String, BTreeSet<ReasonKind>>,
853) -> Result<Option<PlanActionability>, Refusal> {
854    let Some(a) = &def.actionability else {
855        return Ok(None);
856    };
857    let cc = &a.caller_controlled;
858    let party = |causes: &[Cause]| Party::combine(causes.iter().map(|c| cause_party(c, cc)));
859    let for_caller = |r: &RemediationUse| {
860        a.remediations
861            .iter()
862            .any(|d| d.code == r.code() && d.for_party == Party::Caller)
863    };
864    let mut checks = Vec::new();
865    for c in &a.checks {
866        let subject = format!("check:{}", c.id);
867        let decl = &inputs[&c.subject].1;
868        let uncovered: Vec<ProvenanceClass> = sorted(
869            decl.provenance
870                .iter()
871                .filter(|p| !c.on_fail.by_provenance.contains_key(p))
872                .copied(),
873        );
874        if matches!(c.on_fail.default, DefaultConsequence::None) && !uncovered.is_empty() {
875            return Err(refusal(
876                C::UncoveredProvenance,
877                subject,
878                format!(
879                    "uncovered_provenance: {} accepts {} without a branch or default",
880                    c.subject,
881                    uncovered
882                        .iter()
883                        .map(|c| class_name(*c))
884                        .collect::<Vec<_>>()
885                        .join(", ")
886                ),
887            ));
888        }
889        let mut reads = Vec::new();
890        predicate_refs(&c.test, &mut reads);
891        let others: Vec<Cause> = reads
892            .iter()
893            .filter_map(|r| r.strip_prefix("input:"))
894            .map(|r| r.split('/').next().unwrap_or(r).to_string())
895            .filter(|n| n != &c.subject)
896            .filter_map(|n| {
897                inputs
898                    .get(&n)
899                    .map(|(_, d)| accepted(&n, d.provenance.clone()))
900            })
901            .collect();
902        let mut branches = Vec::new();
903        let mut branch = |classes: Vec<ProvenanceClass>, k: &Consequence| -> Result<(), Refusal> {
904            let mut causes = others.clone();
905            causes.push(accepted(&c.subject, classes.clone()));
906            let causes = sort_causes(causes);
907            let p = party(&causes);
908            if let Consequence::Refuse { remediation, .. } = k
909                && for_caller(remediation)
910                && p != Party::Caller
911            {
912                return Err(refusal(
913                    C::CallerCannotAct,
914                    &subject,
915                    format!(
916                        "remediation {:?} is for the caller, but this branch's static party is {}: {}",
917                        remediation.code(),
918                        party_name(p),
919                        not_caller(&causes, cc)
920                    ),
921                ));
922            }
923            branches.push(StaticBranch {
924                classes,
925                consequence: k.kind().into(),
926                causes,
927                party: p,
928            });
929            Ok(())
930        };
931        for (class, k) in &c.on_fail.by_provenance {
932            branch(vec![*class], k)?;
933        }
934        if let DefaultConsequence::Consequence(k) = &c.on_fail.default
935            && !uncovered.is_empty()
936        {
937            branch(uncovered, k)?;
938        }
939        checks.push(StaticCheck {
940            check: c.id.clone(),
941            subject: c.subject.clone(),
942            branches,
943        });
944    }
945    let mut sites = Vec::new();
946    for (n, h) in def.policy.on_unresolved.iter().enumerate() {
947        let site = format!("handler:{n}");
948        // Only the reasons the target can produce (spec 002, 3.11.2): a
949        // listed reason it never produces (a backend reason on an input)
950        // contributes no cause.
951        let possible = reasons.get(&h.target);
952        let covered: Vec<ReasonKind> = match &h.reasons {
953            ReasonSet::Any => possible.into_iter().flatten().copied().collect(),
954            ReasonSet::Only(v) => v
955                .iter()
956                .filter(|r| possible.is_some_and(|p| p.contains(r)))
957                .copied()
958                .collect(),
959        };
960        let causes = sort_causes(
961            covered
962                .iter()
963                .flat_map(|r| static_reason_causes(*r, &h.target, inputs, deps, &site))
964                .collect(),
965        );
966        let p = party(&causes);
967        match &h.action {
968            HandlerAction::PropagateWith {
969                remediation: Some(r),
970                ..
971            } if for_caller(r) && p != Party::Caller => {
972                return Err(refusal(
973                    C::CallerCannotAct,
974                    handler_subject(n),
975                    format!(
976                        "remediation {:?} is for the caller, but this handler's static party is {}",
977                        r.code(),
978                        party_name(p)
979                    ),
980                ));
981            }
982            HandlerAction::NeedsInput { fields, .. } => {
983                needs_caller_fields(&handler_subject(n), fields, inputs, cc)?;
984                sites.push(needs_site(site, fields, inputs, cc));
985                continue;
986            }
987            _ => {}
988        }
989        sites.push(StaticSite {
990            site,
991            causes,
992            party: p,
993        });
994    }
995    for r in &def.policy.rules {
996        if let OutcomeDecl::NeedsInput { fields, .. } = &r.then {
997            needs_caller_fields(&format!("policy:rule:{}", r.id), fields, inputs, cc)?;
998            sites.push(needs_site(format!("rule:{}", r.id), fields, inputs, cc));
999        }
1000    }
1001    Ok(Some(PlanActionability { checks, sites }))
1002}
1003
1004fn needs_caller_fields(
1005    subject: &str,
1006    fields: &[String],
1007    inputs: &BTreeMap<String, (Ty, InputDecl)>,
1008    cc: &[ProvenanceClass],
1009) -> Result<(), Refusal> {
1010    for f in fields {
1011        // `structure` refused unknown fields first; refuse again rather
1012        // than index if that order ever changes.
1013        let Some((_, d)) = inputs.get(f) else {
1014            return Err(invalid(subject, format!("unknown input {f:?}")));
1015        };
1016        if !d.provenance.iter().any(|p| cc.contains(p)) {
1017            return Err(refusal(
1018                C::CallerCannotAct,
1019                subject,
1020                format!("field-not-caller-supplied: {f} accepts no caller-controlled class"),
1021            ));
1022        }
1023    }
1024    Ok(())
1025}
1026
1027/// A `needs_input` site: addressed to the caller by construction (3.7.2).
1028fn needs_site(
1029    site: String,
1030    fields: &[String],
1031    inputs: &BTreeMap<String, (Ty, InputDecl)>,
1032    cc: &[ProvenanceClass],
1033) -> StaticSite {
1034    let causes = sort_causes(
1035        fields
1036            .iter()
1037            .map(|f| {
1038                // Checked by `needs_caller_fields` before any site is built.
1039                let classes = inputs
1040                    .get(f)
1041                    .map(|(_, d)| d.provenance.as_slice())
1042                    .unwrap_or_default()
1043                    .iter()
1044                    .filter(|p| cc.contains(p))
1045                    .copied()
1046                    .collect();
1047                accepted(f, classes)
1048            })
1049            .collect(),
1050    );
1051    StaticSite {
1052        site,
1053        causes,
1054        party: Party::Caller,
1055    }
1056}
1057
1058fn sorted<I: IntoIterator<Item = ProvenanceClass>>(it: I) -> Vec<ProvenanceClass> {
1059    let mut v: Vec<_> = it.into_iter().collect();
1060    v.sort();
1061    v.dedup();
1062    v
1063}
1064
1065/// The causes the caller cannot act on, named with their classes.
1066fn not_caller(causes: &[Cause], cc: &[ProvenanceClass]) -> String {
1067    causes
1068        .iter()
1069        .filter(|c| cause_party(c, cc) != Party::Caller)
1070        .map(|c| match c {
1071            Cause::Input { field, provenance } => {
1072                let classes = match provenance {
1073                    CauseProvenance::Observed { class } => vec![*class],
1074                    CauseProvenance::Accepted { classes } => classes.clone(),
1075                };
1076                let named: Vec<String> = classes
1077                    .iter()
1078                    .filter(|x| class_party(**x, cc) != Party::Caller)
1079                    .map(|x| class_name(*x))
1080                    .collect();
1081                format!("{field} ({})", named.join(", "))
1082            }
1083            other => format!("{other:?}"),
1084        })
1085        .collect::<Vec<_>>()
1086        .join("; ")
1087}
1088
1089pub(crate) fn party_name(p: Party) -> &'static str {
1090    match p {
1091        Party::None => "none",
1092        Party::Caller => "caller",
1093        Party::Host => "host",
1094        Party::Upstream => "upstream",
1095    }
1096}
1097
1098// ---------------------------------------------------------------------------
1099// Evaluation (3.4 to 3.7).
1100
1101/// A notice a check emitted, before its action is computed.
1102#[derive(Debug, Clone)]
1103pub(crate) struct PendingNotice {
1104    pub kind: rustev_contract::judgment::NoticeKind,
1105    pub subject: String,
1106    pub reason: String,
1107    pub causes: Vec<Cause>,
1108    pub address: Address,
1109}
1110
1111/// A refusing check, before its action is computed.
1112#[derive(Debug, Clone)]
1113pub(crate) struct PendingRefusal {
1114    pub check: String,
1115    pub subject: String,
1116    pub causes: Vec<Cause>,
1117    pub remediation: RemediationUse,
1118    pub address: Address,
1119}
1120
1121/// The terminal outcome checks decided (3.4.6).
1122#[derive(Debug, Clone)]
1123pub(crate) enum Terminal {
1124    Refused,
1125    Abstain { check: String },
1126    Escalate { check: String, reason: String },
1127}
1128
1129/// What the checks of one evaluation did.
1130#[derive(Debug, Clone, Default)]
1131pub(crate) struct CheckRun {
1132    pub records: Vec<rustev_contract::actionability::CheckRecord>,
1133    /// Dropped input -> the check that dropped it.
1134    pub dropped: BTreeMap<String, String>,
1135    pub notices: Vec<PendingNotice>,
1136    pub refusals: Vec<PendingRefusal>,
1137    pub terminal: Option<Terminal>,
1138    /// The input references the deciding checks read (3.4.6).
1139    pub deciding_reads: Vec<String>,
1140}
1141
1142/// What an action is computed from, beyond its causes.
1143pub(crate) struct ActionInputs<'a> {
1144    pub caller_controlled: &'a [ProvenanceClass],
1145    /// An explicit address of the site, if any (3.5.3).
1146    pub address: &'a Address,
1147    /// A declared or built-in remediation already resolved to its value,
1148    /// with the party a declared one is `for` (a built-in has none); `None`
1149    /// uses the computed remediation.
1150    pub declared: Option<(rustev_contract::actionability::Remediation, Option<Party>)>,
1151    pub computed: rustev_contract::actionability::Remediation,
1152    pub transient: bool,
1153}
1154
1155/// Build an action from its causes (3.5.2, 3.5.3).
1156pub(crate) fn action(
1157    causes: Vec<Cause>,
1158    a: ActionInputs<'_>,
1159) -> rustev_contract::actionability::Action {
1160    use rustev_contract::actionability::{Action, Override, OverrideBasis, Remediation};
1161    let caused_by = sort_causes(causes);
1162    let computed = Party::combine(
1163        caused_by
1164            .iter()
1165            .map(|c| cause_party(c, a.caller_controlled)),
1166    );
1167    let (remediation, remediation_for) = match a.declared {
1168        Some((r, p)) => (r, p),
1169        None => (a.computed, None),
1170    };
1171    let override_ = match (a.address, remediation_for, &remediation) {
1172        (Address::Override(o), _, _) => Override::Applied {
1173            to: o.to,
1174            basis: OverrideBasis::Declared,
1175            reason: o.reason.clone(),
1176        },
1177        (Address::Computed, Some(p), Remediation::Some { code, .. }) if p != computed => {
1178            Override::Applied {
1179                to: p,
1180                basis: OverrideBasis::Remediation,
1181                reason: code.clone(),
1182            }
1183        }
1184        _ => Override::None,
1185    };
1186    let addressed_to = match &override_ {
1187        Override::Applied { to, .. } => *to,
1188        Override::None => computed,
1189    };
1190    Action {
1191        addressed_to,
1192        computed,
1193        override_,
1194        remediation,
1195        caused_by,
1196        transient: a.transient,
1197    }
1198}
1199
1200/// The computed remediation of a reason (3.6.2).
1201pub(crate) fn computed_remediation(
1202    u: &rustev_contract::judgment::Unresolved,
1203    transient: bool,
1204    failed_step: Option<&str>,
1205) -> rustev_contract::actionability::Remediation {
1206    use rustev_contract::actionability::{Remediation, RemediationValue};
1207    use rustev_contract::judgment::Unresolved as U;
1208    let texts = |key: &str, v: &[String]| {
1209        Remediation::with(key, [(key_for(key), Remediation::texts(v.iter().cloned()))])
1210    };
1211    fn key_for(code: &str) -> String {
1212        if code == "resolve_conflict" {
1213            "facts".into()
1214        } else {
1215            "fields".into()
1216        }
1217    }
1218    match u {
1219        U::MissingEvidence { fields } => texts("provide_fields", fields),
1220        U::StaleEvidence { fields } => texts("refresh_fields", fields),
1221        U::InvalidInput { fields, .. } => texts("correct_fields", fields),
1222        U::Conflict { facts } => texts("resolve_conflict", facts),
1223        U::InsufficientGrade { fields } => texts("improve_evidence", fields),
1224        U::BackendUnavailable { .. } | U::DeadlineExceeded => Remediation::code("retry"),
1225        U::InvalidBackendOutput { .. } if transient => Remediation::code("retry"),
1226        U::InvalidBackendOutput { .. } => Remediation::with(
1227            "report_backend",
1228            [(
1229                "step".to_string(),
1230                RemediationValue::Text(failed_step.unwrap_or_default().to_string()),
1231            )],
1232        ),
1233        U::BudgetExhausted { resource } => Remediation::with(
1234            "raise_budget",
1235            [(
1236                "resource".to_string(),
1237                RemediationValue::Text(resource.clone()),
1238            )],
1239        ),
1240        U::Unsupported { capability } => Remediation::with(
1241            "provide_capability",
1242            [(
1243                "name".to_string(),
1244                RemediationValue::Text(capability.clone()),
1245            )],
1246        ),
1247        U::ScreenUnavailable { .. } | U::Abstained { .. } => Remediation::None,
1248    }
1249}
1250
1251/// Whether retrying the same request unchanged can succeed (3.6.4): only for
1252/// `backend_unavailable`, `deadline_exceeded`, and `invalid_backend_output`
1253/// from a backend whose determinism is not `bitwise`.
1254pub fn transient(
1255    u: &rustev_contract::judgment::Unresolved,
1256    determinism: Option<rustev_contract::definition::Determinism>,
1257) -> bool {
1258    use rustev_contract::definition::Determinism;
1259    use rustev_contract::judgment::Unresolved as U;
1260    match u {
1261        U::BackendUnavailable { .. } | U::DeadlineExceeded => true,
1262        U::InvalidBackendOutput { .. } => determinism != Some(Determinism::Bitwise),
1263        _ => false,
1264    }
1265}
1266
1267/// The input fields a reason names, each reduced to its input (3.5.6).
1268pub(crate) fn reason_fields(u: &rustev_contract::judgment::Unresolved) -> Vec<String> {
1269    use rustev_contract::judgment::Unresolved as U;
1270    let fields: &[String] = match u {
1271        U::MissingEvidence { fields }
1272        | U::StaleEvidence { fields }
1273        | U::InvalidInput { fields, .. }
1274        | U::InsufficientGrade { fields } => fields,
1275        U::Conflict { facts } => facts,
1276        _ => &[],
1277    };
1278    let mut out: Vec<String> = fields
1279        .iter()
1280        .map(|f| f.split('/').next().unwrap_or(f).to_string())
1281        .collect();
1282    out.sort();
1283    out.dedup();
1284    out
1285}
1286
1287#[cfg(test)]
1288mod tests {
1289    use super::*;
1290    use rustev_contract::Document;
1291
1292    /// Check `b` (declared first) reads `a.x`, whose check `a` downgrades it:
1293    /// `b`'s subject still inherits `insufficient_grade`.
1294    #[test]
1295    fn check_reasons_do_not_depend_on_declaration_order() {
1296        let check = |id: &str, subject: &str, reads: &str, k: serde_json::Value| {
1297            serde_json::json!({ "id": id, "subject": subject,
1298                "test": { "present": format!("input:{reads}") },
1299                "on_fail": { "by_provenance": {}, "default": k } })
1300        };
1301        let def = serde_json::json!({
1302            "schema": "rustev.definition/2", "name": "n", "version": "1.0.0", "package": "p",
1303            "inputs": [], "steps": [],
1304            "policy": { "on_unresolved": [], "rules": [], "adjustments": [] },
1305            "limits": { "max_semantic_requests": 0, "on_excess": "refuse", "max_projection_bytes": 1,
1306                        "distribution_tolerance": "0.01", "deadline_ms": 1 },
1307            "actionability": { "caller_controlled": [], "remediations": [], "questions": [],
1308                "constraint_groups": [],
1309                "checks": [
1310                    check("b", "b.y", "a.x", serde_json::json!("abstain")),
1311                    check("a", "a.x", "a.x", serde_json::json!(
1312                        { "downgrade_grade": { "to": "estimated", "notice": "n" } })),
1313                ] } });
1314        let def = Definition::parse(&serde_json::to_vec(&def).unwrap()).unwrap();
1315        let mut reasons = BTreeMap::new();
1316        extend_reasons(&def, &mut reasons);
1317        assert!(reasons["input:b.y"].contains(&ReasonKind::InsufficientGrade));
1318    }
1319}