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