Skip to main content

fno_agents/
delivery_completion.rs

1//! Strict process boundary for explicitly activated generic delivery.
2
3use serde::Deserialize;
4use serde_json::Value;
5use std::collections::HashSet;
6use std::io::Write;
7use std::path::{Path, PathBuf};
8use std::process::Command;
9
10const EVALUATOR_COMMAND_ID: &str = "fno delivery evaluate --json";
11
12#[derive(Debug)]
13pub enum DeliveryCompletion {
14    Inactive,
15    Passed {
16        fact_revision: String,
17        evidence_revision: String,
18        work_order_node_id: String,
19        verdict: Value,
20    },
21    Nonpassing {
22        reason: String,
23        evidence_revision: Option<String>,
24    },
25}
26
27impl DeliveryCompletion {
28    pub fn is_active(&self) -> bool {
29        !matches!(self, Self::Inactive)
30    }
31
32    pub fn delivery_fingerprint(&self, legacy: String) -> String {
33        let revision = match self {
34            Self::Inactive => return legacy,
35            Self::Passed {
36                evidence_revision, ..
37            }
38            | Self::Nonpassing {
39                evidence_revision: Some(evidence_revision),
40                ..
41            } => evidence_revision.as_str(),
42            Self::Nonpassing { .. } => "unavailable",
43        };
44        let (head, tail) = legacy
45            .split_once('|')
46            .unwrap_or((&legacy, "none|none|none"));
47        format!("{head}@delivery:{revision}|{tail}")
48    }
49}
50
51#[derive(Debug)]
52pub struct DeliveryReceipt {
53    pub node: String,
54    pub attempt: String,
55    pub fact_revision: String,
56    pub uri: String,
57}
58
59/// Exact current-read conjunction that authorizes the legacy PR terminal.
60pub fn pr_passes(open: bool, ci: bool, reviewed: bool, head: bool, probes: bool) -> bool {
61    open && ci && reviewed && head && probes
62}
63
64#[derive(Debug, Deserialize)]
65#[serde(deny_unknown_fields)]
66struct Response {
67    version: String,
68    status: String,
69    fact_revision: Value,
70    #[serde(default)]
71    evidence_revision: Option<String>,
72    verdict: Value,
73    diagnostics: Vec<String>,
74}
75
76#[derive(Debug, Deserialize)]
77#[serde(deny_unknown_fields)]
78struct Verdict {
79    evaluator_version: String,
80    #[serde(default, rename = "session_id")]
81    _session_id: Option<String>,
82    work_order_node_id: String,
83    attempt_id: String,
84    aggregate: String,
85    fact_revision: Value,
86    required_requirements: Vec<RequirementBinding>,
87    requirements: Vec<Requirement>,
88    diagnostics: Vec<String>,
89}
90
91#[derive(Debug, Deserialize)]
92#[serde(deny_unknown_fields)]
93struct RequirementBinding {
94    deliverable_id: String,
95    evidence_id: String,
96}
97
98#[derive(Debug, Deserialize)]
99#[serde(deny_unknown_fields)]
100struct Requirement {
101    deliverable_id: String,
102    evidence_id: String,
103    subject_kind: String,
104    subject_id: String,
105    result: String,
106    producers: Vec<String>,
107    source_revisions: Vec<String>,
108    diagnostics: Vec<String>,
109}
110
111pub fn evaluate(fno_bin: &str, cwd: &Path, plan_path: &Path, events: &Path) -> DeliveryCompletion {
112    match activation(plan_path) {
113        Activation::Inactive => return DeliveryCompletion::Inactive,
114        Activation::Invalid(reason) => return nonpassing(reason, None),
115        Activation::Active => {}
116    }
117    let output = match Command::new(fno_bin)
118        .args(["delivery", "evaluate", "--json", "--plan-path"])
119        .arg(plan_path)
120        .arg("--events")
121        .arg(events)
122        .current_dir(cwd)
123        .output()
124    {
125        Ok(output) if output.status.success() => output,
126        Ok(output) => {
127            return nonpassing(
128                format!(
129                    "delivery evaluator exited {:?}: {}",
130                    output.status.code(),
131                    String::from_utf8_lossy(&output.stderr).trim()
132                ),
133                None,
134            )
135        }
136        Err(error) => {
137            return nonpassing(format!("delivery evaluator could not start: {error}"), None)
138        }
139    };
140    parse_response(&output.stdout)
141}
142
143pub fn evaluate_manifest(cwd: &Path, plan_path: Option<&str>, events: &Path) -> DeliveryCompletion {
144    let Some(plan_path) = plan_path else {
145        return DeliveryCompletion::Inactive;
146    };
147    let plan_path = PathBuf::from(plan_path);
148    let plan_path = if plan_path.is_absolute() {
149        plan_path
150    } else {
151        cwd.join(plan_path)
152    };
153    let fno_bin = std::env::var("FNO_LOOPCHECK_FNO_BIN").unwrap_or_else(|_| "fno".into());
154    evaluate(&fno_bin, cwd, &plan_path, events)
155}
156
157#[allow(clippy::too_many_arguments)]
158pub fn gate_output(
159    completion: &DeliveryCompletion,
160    promise: bool,
161    project_events: &Path,
162    global_events: &Path,
163    session_id: &str,
164    manifest_session_id: Option<&str>,
165    expected_node: Option<&str>,
166    intent_source: &str,
167    fingerprint: &str,
168    fires: u64,
169    backstop_tripped: bool,
170    consecutive_unchanged: u64,
171    streak_window_secs: i64,
172    pr_state: &str,
173    ci: &str,
174) -> Option<String> {
175    let DeliveryCompletion::Passed {
176        fact_revision,
177        evidence_revision: _,
178        work_order_node_id,
179        verdict,
180    } = completion
181    else {
182        return match completion {
183            DeliveryCompletion::Inactive => None,
184            DeliveryCompletion::Nonpassing { reason, .. } => Some(nonpassing_output(
185                project_events,
186                global_events,
187                session_id,
188                intent_source,
189                fingerprint,
190                fires,
191                backstop_tripped,
192                promise,
193                consecutive_unchanged,
194                streak_window_secs,
195                pr_state,
196                ci,
197                &format!("generic delivery undeterminable: {reason}"),
198            )),
199            DeliveryCompletion::Passed { .. } => unreachable!(),
200        };
201    };
202    if manifest_session_id.is_none_or(|value| value.trim().is_empty()) {
203        return Some(nonpassing_output(
204            project_events,
205            global_events,
206            session_id,
207            intent_source,
208            fingerprint,
209            fires,
210            backstop_tripped,
211            promise,
212            consecutive_unchanged,
213            streak_window_secs,
214            pr_state,
215            ci,
216            "generic delivery session has no session id binding",
217        ));
218    }
219    let binding_error = match expected_node {
220        Some(expected) if expected == work_order_node_id => None,
221        Some(expected) => Some(format!(
222            "generic delivery verdict node {work_order_node_id} does not match session node {expected}"
223        )),
224        None => Some("generic delivery session has no graph node binding".into()),
225    };
226    if let Some(reason) = binding_error {
227        return Some(nonpassing_output(
228            project_events,
229            global_events,
230            session_id,
231            intent_source,
232            fingerprint,
233            fires,
234            backstop_tripped,
235            promise,
236            consecutive_unchanged,
237            streak_window_secs,
238            pr_state,
239            ci,
240            &reason,
241        ));
242    }
243    if !promise {
244        return Some(nonpassing_output(
245            project_events,
246            global_events,
247            session_id,
248            intent_source,
249            fingerprint,
250            fires,
251            backstop_tripped,
252            promise,
253            consecutive_unchanged,
254            streak_window_secs,
255            pr_state,
256            ci,
257            "generic delivery requires a promise",
258        ));
259    }
260    if !emit_verdict(project_events, global_events, session_id, verdict) {
261        return Some(crate::completion_output::allow_output(
262            "block",
263            None,
264            "generic delivery verdict could not be durably recorded",
265            fires,
266            Some(fingerprint.into()),
267        ));
268    }
269    let terminal_message =
270        format!("generic delivery passed via {EVALUATOR_COMMAND_ID} at {fact_revision}");
271    crate::loopcheck::emit_to_both(
272        project_events,
273        global_events,
274        "loop_check",
275        serde_json::json!({
276            "session_id": session_id, "decision": "allow", "intent": "promise",
277            "intent_source": intent_source, "fingerprint": fingerprint,
278            "fires": fires, "fact_revision": fact_revision,
279            "consecutive_unchanged": consecutive_unchanged,
280            "streak_window_secs": streak_window_secs,
281            "pr_state": pr_state, "ci": ci, "reviewed": false,
282            "review_skipped": true, "fp_read_failed": false
283        }),
284    );
285    Some(crate::completion_output::allow_output(
286        "allow",
287        Some(crate::loopcheck::TerminationReason::DoneDelivery),
288        &terminal_message,
289        fires,
290        Some(fingerprint.into()),
291    ))
292}
293
294#[allow(clippy::too_many_arguments)]
295fn nonpassing_output(
296    project_events: &Path,
297    global_events: &Path,
298    session_id: &str,
299    intent_source: &str,
300    fingerprint: &str,
301    fires: u64,
302    backstop_tripped: bool,
303    promise: bool,
304    consecutive_unchanged: u64,
305    streak_window_secs: i64,
306    pr_state: &str,
307    ci: &str,
308    reason: &str,
309) -> String {
310    if !backstop_tripped {
311        crate::loopcheck::emit_to_both(
312            project_events,
313            global_events,
314            "loop_check",
315            serde_json::json!({
316                "session_id": session_id,
317                "decision": "block",
318                "intent": if promise { "promise" } else { "none" },
319                "intent_source": intent_source,
320                "fingerprint": fingerprint,
321                "fires": fires,
322                "consecutive_unchanged": consecutive_unchanged,
323                "streak_window_secs": streak_window_secs,
324                "pr_state": pr_state, "ci": ci, "reviewed": false,
325                "review_skipped": true, "fp_read_failed": false
326            }),
327        );
328        return crate::completion_output::allow_output(
329            "block",
330            None,
331            reason,
332            fires,
333            Some(fingerprint.into()),
334        );
335    }
336    let message = format!("generic delivery made no progress: {reason}");
337    crate::loopcheck::emit_to_both(
338        project_events,
339        global_events,
340        "termination",
341        serde_json::json!({
342            "session_id": session_id,
343            "reason": "NoProgress",
344            "message": message
345        }),
346    );
347    crate::loopcheck::emit_to_both(
348        project_events,
349        global_events,
350        "loop_check",
351        serde_json::json!({
352            "session_id": session_id,
353            "decision": "allow",
354            "intent": "backstop",
355            "intent_source": intent_source,
356            "fingerprint": fingerprint,
357            "fires": fires,
358            "consecutive_unchanged": consecutive_unchanged,
359            "streak_window_secs": streak_window_secs,
360            "pr_state": pr_state, "ci": ci, "reviewed": false,
361            "review_skipped": true, "fp_read_failed": false
362        }),
363    );
364    crate::completion_output::allow_output(
365        "allow",
366        Some(crate::loopcheck::TerminationReason::NoProgress),
367        &message,
368        fires,
369        Some(fingerprint.into()),
370    )
371}
372
373pub fn emit_verdict(
374    project_events: &Path,
375    global_events: &Path,
376    session_id: &str,
377    verdict: &Value,
378) -> bool {
379    let mut data = verdict.clone();
380    let Some(mapping) = data.as_object_mut() else {
381        return false;
382    };
383    mapping.insert("session_id".into(), Value::String(session_id.into()));
384    let envelope = serde_json::json!({
385        "ts": chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(),
386        "type": "delivery_verdict_evaluated",
387        "source": "target",
388        "data": data,
389    });
390    let Ok(mut line) = serde_json::to_vec(&envelope) else {
391        return false;
392    };
393    line.push(b'\n');
394    let durable = append(project_events, &line);
395    if project_events != global_events {
396        let _ = append(global_events, &line);
397    }
398    durable
399}
400
401pub fn emit_terminal(
402    project_events: &Path,
403    global_events: &Path,
404    session_id: &str,
405    message: &str,
406) -> bool {
407    let envelope = serde_json::json!({
408        "ts": chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(),
409        "type": "termination",
410        "source": "hook",
411        "data": {
412            "session_id": session_id,
413            "reason": "DoneDelivery",
414            "message": message
415        },
416    });
417    let Ok(mut line) = serde_json::to_vec(&envelope) else {
418        return false;
419    };
420    line.push(b'\n');
421    let durable = append(project_events, &line);
422    if project_events != global_events {
423        let _ = append(global_events, &line);
424    }
425    durable
426}
427
428fn append(path: &Path, line: &[u8]) -> bool {
429    if let Some(parent) = path.parent() {
430        let _ = std::fs::create_dir_all(parent);
431    }
432    match std::fs::OpenOptions::new()
433        .create(true)
434        .append(true)
435        .open(path)
436    {
437        Ok(mut file) => match file.write_all(line) {
438            Ok(()) => true,
439            Err(error) => {
440                eprintln!(
441                    "delivery completion: append {} failed: {error}",
442                    path.display()
443                );
444                false
445            }
446        },
447        Err(error) => {
448            eprintln!(
449                "delivery completion: open {} failed: {error}",
450                path.display()
451            );
452            false
453        }
454    }
455}
456
457pub fn selected_receipt(
458    events: &Path,
459    expected_node: Option<&str>,
460    session_id: &str,
461) -> Option<DeliveryReceipt> {
462    let content = std::fs::read_to_string(events).ok()?;
463    for line in content.lines().rev() {
464        let event: Value = match serde_json::from_str(line) {
465            Ok(event) => event,
466            Err(_) => continue,
467        };
468        if event.get("type").and_then(Value::as_str) != Some("delivery_verdict_evaluated")
469            || event.get("source").and_then(Value::as_str) != Some("target")
470        {
471            continue;
472        }
473        let data = event.get("data")?;
474        match data.get("session_id").and_then(Value::as_str) {
475            Some(other) if other != session_id => continue,
476            Some(_) => {}
477            None => return None,
478        }
479        let revision = data.get("fact_revision")?.as_str()?;
480        let verdict = strict_passed_verdict(data, revision)?;
481        let node = verdict.work_order_node_id.as_str();
482        if expected_node.is_some_and(|expected| expected != node) {
483            return None;
484        }
485        let attempt = verdict.attempt_id.as_str();
486        return Some(DeliveryReceipt {
487            node: node.into(),
488            attempt: attempt.into(),
489            fact_revision: revision.into(),
490            uri: format!("fno-delivery://{node}/{attempt}/{revision}"),
491        });
492    }
493    None
494}
495
496pub fn write_receipt_handoff(
497    dir: &Path,
498    session_id: &str,
499    receipt: &DeliveryReceipt,
500) -> Result<String, String> {
501    std::fs::create_dir_all(dir).map_err(|error| error.to_string())?;
502    let prefix: String = session_id.chars().take(16).collect();
503    let path = dir.join(format!(
504        "{}-{prefix}-delivery.md",
505        chrono::Utc::now().format("%Y-%m-%d")
506    ));
507    let body = format!(
508        "# generic delivery receipt\n\n- session: `{session_id}`\n- node: `{}`\n- attempt: `{}`\n- fact_revision: `{}`\n- receipt: {}\n",
509        receipt.node, receipt.attempt, receipt.fact_revision, receipt.uri
510    );
511    std::fs::write(&path, body).map_err(|error| error.to_string())?;
512    Ok(path.to_string_lossy().into_owned())
513}
514
515enum Activation {
516    Inactive,
517    Active,
518    Invalid(String),
519}
520
521fn activation(plan_path: &Path) -> Activation {
522    let text = match std::fs::read_to_string(plan_path) {
523        Ok(text) => text,
524        Err(error) => {
525            return Activation::Invalid(format!("delivery plan could not be read: {error}"))
526        }
527    };
528    let Some(rest) = text.strip_prefix("---") else {
529        return Activation::Inactive;
530    };
531    let Some(end) = rest.find("\n---") else {
532        return Activation::Invalid("delivery plan frontmatter is unterminated".into());
533    };
534    let frontmatter = match serde_yaml_ng::from_str::<serde_yaml_ng::Value>(&rest[..end]) {
535        Ok(frontmatter) => frontmatter,
536        Err(error) => {
537            return Activation::Invalid(format!("delivery plan frontmatter is malformed: {error}"))
538        }
539    };
540    let Some(mapping) = frontmatter.as_mapping() else {
541        return Activation::Invalid("delivery plan frontmatter is not a mapping".into());
542    };
543    if mapping
544        .get(serde_yaml_ng::Value::from("completion"))
545        .and_then(serde_yaml_ng::Value::as_str)
546        != Some("delivery")
547    {
548        return Activation::Inactive;
549    }
550    let company_key = serde_yaml_ng::Value::from("company_work");
551    let Some(company_value) = mapping.get(&company_key) else {
552        return Activation::Inactive;
553    };
554    let Some(company) = company_value.as_mapping() else {
555        return Activation::Invalid("delivery plan company_work is not a mapping".into());
556    };
557    let work_order_key = serde_yaml_ng::Value::from("work_order");
558    let work_order_valid = match company.get(&work_order_key) {
559        None => false,
560        Some(value) => {
561            let Some(work_order) = value.as_mapping() else {
562                return Activation::Invalid(
563                    "delivery plan company_work.work_order is not a mapping".into(),
564                );
565            };
566            let node = work_order.get(serde_yaml_ng::Value::from("node_id"));
567            let attempt = work_order.get(serde_yaml_ng::Value::from("attempt_id"));
568            match (node, attempt) {
569                (None, _) | (_, None) => {
570                    return Activation::Invalid(
571                        "delivery plan work_order is missing node_id or attempt_id".into(),
572                    );
573                }
574                (Some(node), Some(attempt)) => {
575                    let Some(node) = node.as_str() else {
576                        return Activation::Invalid(
577                            "delivery plan work_order.node_id is not a string".into(),
578                        );
579                    };
580                    let Some(attempt) = attempt.as_str() else {
581                        return Activation::Invalid(
582                            "delivery plan work_order.attempt_id is not a string".into(),
583                        );
584                    };
585                    if node.trim().is_empty() || attempt.trim().is_empty() {
586                        return Activation::Invalid(
587                            "delivery plan work_order node_id or attempt_id is empty".into(),
588                        );
589                    }
590                    true
591                }
592            }
593        }
594    };
595    let deliverables_key = serde_yaml_ng::Value::from("deliverables");
596    let Some(deliverables_value) = company.get(&deliverables_key) else {
597        return Activation::Inactive;
598    };
599    let Some(deliverables) = deliverables_value.as_sequence() else {
600        return Activation::Invalid("delivery plan company_work.deliverables is not a list".into());
601    };
602    let mut has_required = false;
603    for deliverable in deliverables {
604        let Some(deliverable) = deliverable.as_mapping() else {
605            return Activation::Invalid("delivery plan deliverable is not a mapping".into());
606        };
607        let required_key = serde_yaml_ng::Value::from("required_evidence_ids");
608        let Some(required_value) = deliverable.get(&required_key) else {
609            continue;
610        };
611        let Some(required_ids) = required_value.as_sequence() else {
612            return Activation::Invalid("delivery plan required_evidence_ids is not a list".into());
613        };
614        for evidence_id in required_ids {
615            let Some(evidence_id) = evidence_id.as_str() else {
616                return Activation::Invalid(
617                    "delivery plan required evidence id is not a string".into(),
618                );
619            };
620            if evidence_id.trim().is_empty() {
621                return Activation::Invalid("delivery plan required evidence id is empty".into());
622            }
623            has_required = true;
624        }
625    }
626    if work_order_valid && !deliverables.is_empty() && has_required {
627        Activation::Active
628    } else {
629        Activation::Inactive
630    }
631}
632
633fn parse_response(raw: &[u8]) -> DeliveryCompletion {
634    let response: Response = match serde_json::from_slice(raw) {
635        Ok(response) => response,
636        Err(error) => {
637            return nonpassing(
638                format!("malformed delivery evaluator response: {error}"),
639                None,
640            )
641        }
642    };
643    if response.version != "delivery-evaluate-response.v1" {
644        return nonpassing("unknown delivery evaluator response version".into(), None);
645    }
646    match response.status.as_str() {
647        "inactive" if response.fact_revision.is_null() && response.verdict.is_null() => nonpassing(
648            "active delivery plan evaluator returned inactive".into(),
649            response.evidence_revision,
650        ),
651        "undeterminable" if response.fact_revision.is_null() && response.verdict.is_null() => {
652            nonpassing(response.diagnostics.join("; "), response.evidence_revision)
653        }
654        "evaluated" => parse_evaluated(response),
655        _ => nonpassing(
656            "invalid delivery evaluator response state".into(),
657            response.evidence_revision,
658        ),
659    }
660}
661
662fn parse_evaluated(response: Response) -> DeliveryCompletion {
663    if !response.diagnostics.is_empty() {
664        let details = response.diagnostics.join("; ");
665        return nonpassing(
666            format!("evaluated response diagnostics: {details}"),
667            response.evidence_revision,
668        );
669    }
670    let Some(fact_revision) = response.fact_revision.as_str() else {
671        return nonpassing(
672            "evaluated response has no fact revision".into(),
673            response.evidence_revision,
674        );
675    };
676    if fact_revision.trim().is_empty() {
677        return nonpassing(
678            "evaluated response has blank fact revision".into(),
679            response.evidence_revision,
680        );
681    }
682    let Some(evidence_revision) = response.evidence_revision.clone() else {
683        return nonpassing("evaluated response has no evidence revision".into(), None);
684    };
685    if evidence_revision.trim().is_empty() {
686        return nonpassing(
687            "evaluated response has blank evidence revision".into(),
688            None,
689        );
690    }
691    let Some(verdict) = strict_passed_verdict(&response.verdict, fact_revision) else {
692        let details = verdict_rejection_diagnostics(&response.verdict);
693        let reason = if details.is_empty() {
694            "delivery verdict is nonpassing or incomplete".into()
695        } else {
696            format!(
697                "delivery verdict is nonpassing or incomplete: {}",
698                details.join("; ")
699            )
700        };
701        return nonpassing(reason, Some(evidence_revision));
702    };
703    DeliveryCompletion::Passed {
704        fact_revision: fact_revision.to_string(),
705        evidence_revision,
706        work_order_node_id: verdict.work_order_node_id,
707        verdict: response.verdict,
708    }
709}
710
711fn nonpassing(reason: String, evidence_revision: Option<String>) -> DeliveryCompletion {
712    DeliveryCompletion::Nonpassing {
713        reason,
714        evidence_revision,
715    }
716}
717
718fn strict_passed_verdict(value: &Value, fact_revision: &str) -> Option<Verdict> {
719    let verdict: Verdict = serde_json::from_value(value.clone()).ok()?;
720    let required: Vec<_> = verdict
721        .required_requirements
722        .iter()
723        .map(|item| (item.deliverable_id.as_str(), item.evidence_id.as_str()))
724        .collect();
725    let rows: Vec<_> = verdict
726        .requirements
727        .iter()
728        .map(|item| (item.deliverable_id.as_str(), item.evidence_id.as_str()))
729        .collect();
730    let unique: HashSet<_> = required.iter().copied().collect();
731    let complete = verdict.evaluator_version == "delivery-evaluator.v1"
732        && nonblank(&verdict.work_order_node_id)
733        && nonblank(&verdict.attempt_id)
734        && verdict.aggregate == "passed"
735        && verdict.fact_revision.as_str() == Some(fact_revision)
736        && !required.is_empty()
737        && required
738            .iter()
739            .all(|(deliverable_id, evidence_id)| nonblank(deliverable_id) && nonblank(evidence_id))
740        && required == rows
741        && unique.len() == required.len()
742        && verdict.requirements.iter().all(valid_passed_requirement)
743        && verdict.diagnostics.is_empty();
744    complete.then_some(verdict)
745}
746
747fn verdict_rejection_diagnostics(value: &Value) -> Vec<String> {
748    let Ok(verdict) = serde_json::from_value::<Verdict>(value.clone()) else {
749        return Vec::new();
750    };
751    let mut details: Vec<String> = verdict
752        .diagnostics
753        .iter()
754        .filter(|diagnostic| nonblank(diagnostic))
755        .map(|diagnostic| format!("verdict: {diagnostic}"))
756        .collect();
757    for requirement in &verdict.requirements {
758        if requirement.result == "passed" && requirement.diagnostics.is_empty() {
759            continue;
760        }
761        let producers = if requirement.producers.is_empty() {
762            "none".into()
763        } else {
764            requirement.producers.join(",")
765        };
766        let diagnostics = if requirement.diagnostics.is_empty() {
767            "no diagnostic supplied".into()
768        } else {
769            requirement.diagnostics.join(", ")
770        };
771        details.push(format!(
772            "{}/{} result={} producers=[{}]: {}",
773            requirement.deliverable_id,
774            requirement.evidence_id,
775            requirement.result,
776            producers,
777            diagnostics,
778        ));
779    }
780    details
781}
782
783fn valid_passed_requirement(requirement: &Requirement) -> bool {
784    const SUBJECTS: &[&str] = &[
785        "artifact",
786        "review",
787        "approval",
788        "probe",
789        "acknowledgment",
790        "deliverable",
791        "effect",
792    ];
793    nonblank(&requirement.deliverable_id)
794        && nonblank(&requirement.evidence_id)
795        && SUBJECTS.contains(&requirement.subject_kind.as_str())
796        && nonblank(&requirement.subject_id)
797        && requirement.result == "passed"
798        && !requirement.producers.is_empty()
799        && requirement.producers.iter().all(|value| nonblank(value))
800        && !requirement.source_revisions.is_empty()
801        && requirement
802            .source_revisions
803            .iter()
804            .all(|value| nonblank(value))
805        && requirement.diagnostics.is_empty()
806}
807
808fn nonblank(value: &str) -> bool {
809    !value.trim().is_empty()
810}