1use 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
39pub(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 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
80pub(crate) fn handler_subject(n: usize) -> String {
82 format!("policy:on_unresolved[{n}]")
83}
84
85pub(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
95pub(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
104pub(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
125pub fn computed_party(causes: &[Cause], caller_controlled: &[ProvenanceClass]) -> Party {
128 Party::combine(causes.iter().map(|c| cause_party(c, caller_controlled)))
129}
130
131pub(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
155fn 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
355pub(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
574pub(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 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
689fn 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
699pub(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 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 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
757fn 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
777fn 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 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 origin: Origin::Upstream,
839 })
840 })
841 .collect(),
842 K::Abstained | K::Skipped => vec![Cause::Policy { site: site.into() }],
843 }
844}
845
846pub(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 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 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
1027fn 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 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
1065fn 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#[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#[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#[derive(Debug, Clone)]
1123pub(crate) enum Terminal {
1124 Refused,
1125 Abstain { check: String },
1126 Escalate { check: String, reason: String },
1127}
1128
1129#[derive(Debug, Clone, Default)]
1131pub(crate) struct CheckRun {
1132 pub records: Vec<rustev_contract::actionability::CheckRecord>,
1133 pub dropped: BTreeMap<String, String>,
1135 pub notices: Vec<PendingNotice>,
1136 pub refusals: Vec<PendingRefusal>,
1137 pub terminal: Option<Terminal>,
1138 pub deciding_reads: Vec<String>,
1140}
1141
1142pub(crate) struct ActionInputs<'a> {
1144 pub caller_controlled: &'a [ProvenanceClass],
1145 pub address: &'a Address,
1147 pub declared: Option<(rustev_contract::actionability::Remediation, Option<Party>)>,
1151 pub computed: rustev_contract::actionability::Remediation,
1152 pub transient: bool,
1153}
1154
1155pub(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
1200pub(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
1251pub 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
1267pub(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 #[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}