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 { .. } | Cause::Deadline | Cause::Capability { .. } => Party::Host,
118 Cause::Policy { .. } => Party::None,
119 }
120}
121
122pub fn computed_party(causes: &[Cause], caller_controlled: &[ProvenanceClass]) -> Party {
125 Party::combine(causes.iter().map(|c| cause_party(c, caller_controlled)))
126}
127
128pub(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
152fn 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
352pub(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
571pub(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 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
686fn 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
696pub(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 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 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
754fn 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
774fn 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 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 origin: Origin::Upstream,
836 })
837 })
838 .collect(),
839 K::Abstained | K::Skipped => vec![Cause::Policy { site: site.into() }],
840 }
841}
842
843pub(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 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 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
1024fn 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 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
1062fn 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#[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#[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#[derive(Debug, Clone)]
1120pub(crate) enum Terminal {
1121 Refused,
1122 Abstain { check: String },
1123 Escalate { check: String, reason: String },
1124}
1125
1126#[derive(Debug, Clone, Default)]
1128pub(crate) struct CheckRun {
1129 pub records: Vec<rustev_contract::actionability::CheckRecord>,
1130 pub dropped: BTreeMap<String, String>,
1132 pub notices: Vec<PendingNotice>,
1133 pub refusals: Vec<PendingRefusal>,
1134 pub terminal: Option<Terminal>,
1135 pub deciding_reads: Vec<String>,
1137}
1138
1139pub(crate) struct ActionInputs<'a> {
1141 pub caller_controlled: &'a [ProvenanceClass],
1142 pub address: &'a Address,
1144 pub declared: Option<(rustev_contract::actionability::Remediation, Option<Party>)>,
1148 pub computed: rustev_contract::actionability::Remediation,
1149 pub transient: bool,
1150}
1151
1152pub(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
1197pub(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
1248pub 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
1264pub(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 #[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}