openlatch-client 0.3.3

OpenLatch runtime enforcement node — the capture-and-enforce adapter that evaluates every covered action against a coding agent's Autonomy Zone before it runs
//! Tier 3 — the `t3_hold` TRIGGER decision, and nothing else. Plan 02 §2, the
//! `tier3.rs` row.
//!
//! The one question this file answers is *"does this atom's hold fire?"*. The
//! queue, the deadline and the long poll are the daemon's; this returns
//! `Decision.hold = Some(HoldRequest { .. })` and stops. Resolving a hold —
//! approve, reject, time out — happens outside this pure function, which is why
//! the request carries **the verdict each answer would give** rather than an
//! answer. Putting any of that lifecycle here would put a queue inside a module
//! whose whole contract is that it holds nothing between calls.
//!
//! A hold is an ASK that waits, so a fired hold contributes [`Verdict::Ask`]. An
//! unknown trigger routes to the artifact's `on_inconclusive` exactly as Tier 1's
//! does; a false trigger contributes nothing.
//!
//! # A monitor hold opens no hold, and that is the join's doing
//!
//! A monitor artifact still evaluates and still reports `would_have_verdict`, but
//! **monitor never joins**, so its `HoldRequest` never reaches the decision. That
//! is one rule in one place (`join`), not a second mode check here — a hold that
//! suppressed itself would be a third place the monitor rule lives.

use crate::generated::types::{T3Hold, Verdict};

use super::bundle::LoadedArtifact;
use super::kleene::Kleene;
use super::tier1::ScanTable;
use super::tier2::{build, declared_mode, on_inconclusive_verdict};
use super::types::{Contribution, EvalContext, HoldRequest};

/// Reject a hold whose trigger tree the evaluator cannot read.
///
/// The trigger is the **same shape as a Tier 1 tree**, so one evaluator reads
/// both — this delegates rather than growing a second tree validator.
pub fn validate_hold(body: &T3Hold) -> Result<(), String> {
    super::tier1::validate_node(body.trigger.as_ref())
}

/// One `t3_hold` artifact's contribution, or `None` when its trigger is false.
pub fn contribution(
    artifact: &LoadedArtifact,
    body: &T3Hold,
    ctx: &mut EvalContext<'_>,
    scan: &ScanTable,
) -> Option<Contribution> {
    let trigger = body.trigger.as_ref()?;
    let mode = declared_mode(artifact);
    let reason = body.reason.clone().unwrap_or_default();

    // The hold's own context, so the ⊥ facts its trigger notes are its own and do
    // not leak into the next artifact's list.
    let mut child = ctx.fork();
    let value = super::tier1::evaluate_node(trigger, &mut child, scan);
    ctx.merge_warnings(&child);

    match value {
        // A known FALSE: whatever went ⊥ inside the trigger could not have
        // changed the answer, so it is not reported.
        Kleene::False => None,
        Kleene::Unknown(_) => Some(build(
            artifact,
            mode.clone(),
            on_inconclusive_verdict(artifact, &mode),
            reason,
            child.inconclusive,
            Vec::new(),
        )),
        Kleene::True => {
            let mut contribution =
                build(artifact, mode, Verdict::Ask, reason, Vec::new(), Vec::new());
            contribution.hold = Some(HoldRequest {
                resolve: body.resolve.clone(),
                directive_template_id: body.directive_template_id.clone(),
                max_attempts: body.max_attempts,
                timeout_s: body.timeout_s,
                on_timeout: body.on_timeout,
                verdict_on_approve: body.verdict_on_approve,
                verdict_on_reject: body.verdict_on_reject,
            });
            Some(contribution)
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::generated::types::{PolicyArtifact, T1Node};
    use crate::zone_eval::bundle::ArtifactBody;
    use crate::zone_eval::facts::FactSet;
    use crate::zone_eval::types::{Classification, Effect, Event, MODE_MONITOR};

    const NOW: i64 = 1_756_742_400_000;
    const REASON: &str = "a force-push to a protected branch cannot be undone";

    fn node(value: serde_json::Value) -> T1Node {
        serde_json::from_value(value).expect("the trigger parses")
    }

    /// `delete × vcs_remote` — true exactly when the classifier produced that
    /// tuple, which is what [`fired`] supplies and [`quiet`] does not.
    fn vcs_trigger() -> T1Node {
        node(serde_json::json!({
            "op": "leaf",
            "leaf": {"pred": "effect", "effect": {"verb": "delete", "target_class": "vcs_remote"}}
        }))
    }

    /// A leaf over a fact the bundle does not ship: ⊥, always.
    fn unresolvable_trigger() -> T1Node {
        node(serde_json::json!({
            "op": "leaf",
            "leaf": {"pred": "fact", "fact": {"fact_id": "change_ticket", "op": "equals", "value": true}}
        }))
    }

    fn hold_body(trigger: Option<T1Node>) -> T3Hold {
        T3Hold {
            trigger,
            reason: Some(REASON.to_string()),
            resolve: Some("ask_human".to_string()),
            directive_template_id: Some("tmpl-vcs-hold".to_string()),
            max_attempts: Some(2),
            timeout_s: Some(120),
            on_timeout: Some(Verdict::Block),
            verdict_on_approve: Some(Verdict::Allow),
            verdict_on_reject: Some(Verdict::Block),
        }
    }

    fn artifact(mode: &str, on_inconclusive: &str) -> LoadedArtifact {
        LoadedArtifact {
            envelope: PolicyArtifact {
                artifact_id: Some("sa2".to_string()),
                atom_id: Some("atom-sa2".to_string()),
                tier: Some(3),
                mode: Some(mode.to_string().into()),
                on_inconclusive: Some(on_inconclusive.to_string().into()),
                ..Default::default()
            },
            body: ArtifactBody::T3(Box::new(hold_body(Some(vcs_trigger())))),
        }
    }

    /// The classification that makes the VCS trigger TRUE.
    fn fired() -> Classification {
        Classification {
            effects: vec![Effect {
                verb: "delete".to_string().into(),
                target_class: "vcs_remote".to_string().into(),
                attrs: serde_json::Map::new(),
            }],
            ..Default::default()
        }
    }

    fn evaluate(
        artifact: &LoadedArtifact,
        body: &T3Hold,
        classification: &Classification,
    ) -> Option<Contribution> {
        let event = Event {
            event_type: "pre_tool_use".to_string(),
            tool_name: "Bash".to_string(),
            tool_input: serde_json::json!({"command": "git push --force origin main"}),
            ..Default::default()
        };
        let facts = FactSet::default();
        let mut ctx = EvalContext::new(&event, classification, &facts, NOW);
        contribution(artifact, body, &mut ctx, &ScanTable::default())
    }

    #[test]
    fn a_hold_with_no_trigger_contributes_nothing() {
        let artifact = artifact("enforce", "ask");
        assert!(evaluate(&artifact, &hold_body(None), &fired()).is_none());
    }

    #[test]
    fn a_false_trigger_contributes_nothing() {
        // Nothing to ask about: the action this hold names did not happen.
        let artifact = artifact("enforce", "ask");
        let body = hold_body(Some(vcs_trigger()));
        assert!(evaluate(&artifact, &body, &Classification::default()).is_none());
    }

    #[test]
    fn a_matching_trigger_opens_a_hold_and_contributes_ask() {
        // A hold is an ASK that waits. The verdict is `ask` whatever the answers
        // would be — resolving it is the daemon's, outside this pure function.
        let artifact = artifact("enforce", "ask");
        let body = hold_body(Some(vcs_trigger()));
        let contribution = evaluate(&artifact, &body, &fired()).expect("the trigger matched");
        assert_eq!(contribution.verdict, Verdict::Ask);
        let hold = contribution.hold.expect("a fired trigger opens a hold");
        assert_eq!(hold.resolve.as_deref(), Some("ask_human"));
        assert_eq!(hold.directive_template_id.as_deref(), Some("tmpl-vcs-hold"));
        assert_eq!(hold.max_attempts, Some(2));
        assert_eq!(hold.timeout_s, Some(120));
        assert_eq!(hold.on_timeout, Some(Verdict::Block));
        assert_eq!(hold.verdict_on_approve, Some(Verdict::Allow));
        assert_eq!(hold.verdict_on_reject, Some(Verdict::Block));
    }

    #[test]
    fn the_trigger_decision_is_all_this_returns() {
        // The queue, the deadline and the long poll belong to a later plan. What
        // is asserted here is the absence of lifecycle: the request carries the
        // verdict each ANSWER would give, never an answer, and no state moved.
        let artifact = artifact("enforce", "ask");
        let body = hold_body(Some(vcs_trigger()));
        let first = evaluate(&artifact, &body, &fired()).expect("fired");
        let second = evaluate(&artifact, &body, &fired()).expect("fired again");
        assert_eq!(first, second, "nothing was consumed by the first call");
    }

    #[test]
    fn an_inconclusive_trigger_takes_the_artifacts_on_inconclusive_and_opens_no_hold() {
        let artifact = artifact("enforce", "block");
        let body = hold_body(Some(unresolvable_trigger()));
        let contribution = evaluate(&artifact, &body, &fired()).expect("⊥ still contributes");
        assert_eq!(contribution.verdict, Verdict::Block);
        assert_eq!(contribution.inconclusive, vec!["change_ticket".to_string()]);
        assert!(
            contribution.hold.is_none(),
            "an undecided trigger opens no hold — there is nothing to ask about yet"
        );
    }

    #[test]
    fn a_monitor_hold_is_always_allow_and_flag_when_inconclusive() {
        let artifact = artifact(MODE_MONITOR, "block");
        let body = hold_body(Some(unresolvable_trigger()));
        let contribution = evaluate(&artifact, &body, &fired()).expect("monitor still contributes");
        assert_eq!(
            contribution.verdict,
            Verdict::Allow,
            "monitor never blocks, not even on a fact it could not read"
        );
        assert_eq!(contribution.mode.as_str(), MODE_MONITOR);
    }

    #[test]
    fn a_monitor_hold_still_reports_its_hold_and_the_join_is_what_drops_it() {
        // *Monitor never joins* is ONE rule in ONE place. A hold that suppressed
        // itself here would be a second copy of it, and the two would drift.
        let artifact = artifact(MODE_MONITOR, "ask");
        let body = hold_body(Some(vcs_trigger()));
        let contribution = evaluate(&artifact, &body, &fired()).expect("fired");
        assert!(contribution.hold.is_some());
        assert!(!contribution.is_enforcing());
    }

    #[test]
    fn the_reason_is_the_bodys_and_reaches_the_developer_verbatim() {
        let artifact = artifact("enforce", "ask");
        let body = hold_body(Some(vcs_trigger()));
        let contribution = evaluate(&artifact, &body, &fired()).expect("fired");
        assert_eq!(contribution.reason, REASON);
    }

    #[test]
    fn validate_hold_rejects_a_body_with_no_trigger() {
        assert!(validate_hold(&hold_body(None)).is_err());
        assert!(validate_hold(&hold_body(Some(vcs_trigger()))).is_ok());
    }
}