zynk 1.1.0

Portable protocol and helper CLI for multi-agent collaboration.
use crate::{CliError, CliResult};
use serde::{Deserialize, Serialize};

/// The canonical operator modes a `mode-switch` decision can target. These are the
/// five workflow modes accepted in ADR 020 (`decisions/020-mode-switching.md:10-17`):
/// Brainstorm, Decide, Review, Validate, Debug — lowercased to match the `mode`
/// string convention.
pub const CANONICAL_MODES: &[&str] = &["brainstorm", "decide", "review", "validate", "debug"];

/// ADR 033 D4 / spec §1a: the decision `record_type` set — the single source of
/// truth for "is this audit row a decision?". Consumed by `validate_audit_args`
/// to forbid `delivery_status=sent` on a decision row (a decision is
/// observed/operator, never a delivered message), so the invariant holds for ALL
/// audit producers, not just the structural `decide` path. Kept consistent with
/// `Decision::record_type()` (enforced by `record_types_match_const`).
pub const DECISION_RECORD_TYPES: &[&str] = &[
    "gate-decision",
    "conflict-resolve",
    "mode-switch",
    "interrupt",
    "redirect",
];

/// ADR 024 §1a + ADR 034 D8 + ADR 036: the non-transport operator-proof record
/// types — every decision row, `reveal`, AND `participant-overlay` — which must
/// never be `delivery_status=sent` (they are observed/operator events, not
/// delivered messages). Used by `validate_audit_args` so the invariant holds for
/// ALL audit producers, not just the structural decide/reveal/assign paths. NOTE:
/// `reveal` and `participant-overlay` are intentionally NOT members of
/// `DECISION_RECORD_TYPES` (they are not decisions — `db.rs` decision
/// projection/overlay must keep treating them as non-decisions); they live only in
/// THIS sent-guard set. The CLI-layer guard is defense-in-depth: the v10 DB trigger
/// (ADR 036 T1) already rejects a `sent` participant-overlay row at the choke point.
pub fn is_non_transport_proof_record_type(record_type: &str) -> bool {
    DECISION_RECORD_TYPES.contains(&record_type)
        || record_type == "reveal"
        || record_type == crate::overlay::OVERLAY_RECORD_TYPE
}

/// ADR 033 D4: the typed operator-decision model. A `#[serde(tag = "decision")]`
/// enum mirroring the M1 `WorkEventPayload` pattern — per-type `validate()` plus
/// serde_norway `to_storage`/`from_storage` round-trips.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "decision", rename_all = "kebab-case")]
pub enum Decision {
    Gate {
        target_work_event_id: i64,
        verdict: String,
        note: Option<String>,
    },
    Conflict {
        target_work_event_id: i64,
        resolution: String,
        note: Option<String>,
    },
    Mode {
        mode_to: String,
    },
    Interrupt {
        reason: Option<String>,
    },
    Redirect {
        target_agent: String,
        reason: Option<String>,
    },
}

impl Decision {
    /// The storage `record_type` discriminator for this decision.
    pub fn record_type(&self) -> &'static str {
        match self {
            Decision::Gate { .. } => "gate-decision",
            Decision::Conflict { .. } => "conflict-resolve",
            Decision::Mode { .. } => "mode-switch",
            Decision::Interrupt { .. } => "interrupt",
            Decision::Redirect { .. } => "redirect",
        }
    }

    /// The work-event this decision binds to, if any (Gate/Conflict reference a
    /// target work-event; mode/interrupt/redirect do not).
    pub fn target_work_event_id(&self) -> Option<i64> {
        match self {
            Decision::Gate {
                target_work_event_id,
                ..
            }
            | Decision::Conflict {
                target_work_event_id,
                ..
            } => Some(*target_work_event_id),
            _ => None,
        }
    }

    /// ADR 033 D4: validate the bound per-type contract. Reject malformed BEFORE
    /// any write (ADR 029 discipline) — never store-bad / silently-drop.
    pub fn validate(&self) -> CliResult<()> {
        let nonempty = |s: &str, f: &str| -> CliResult<()> {
            if s.trim().is_empty() {
                Err(CliError::usage(format!(
                    "decision field {f} must be non-empty"
                )))
            } else {
                Ok(())
            }
        };
        match self {
            Decision::Gate { verdict, .. } => {
                if !matches!(verdict.as_str(), "approve" | "request-changes") {
                    return Err(CliError::usage(format!(
                        "gate verdict must be one of approve/request-changes (got {verdict:?})"
                    )));
                }
                Ok(())
            }
            Decision::Conflict { resolution, .. } => nonempty(resolution, "resolution"),
            Decision::Mode { mode_to } => {
                if !CANONICAL_MODES.contains(&mode_to.as_str()) {
                    return Err(CliError::usage(format!(
                        "mode-switch mode_to must be one of {} (got {mode_to:?})",
                        CANONICAL_MODES.join("/")
                    )));
                }
                Ok(())
            }
            Decision::Interrupt { .. } => Ok(()),
            Decision::Redirect { target_agent, .. } => nonempty(target_agent, "target_agent"),
        }
    }

    /// Serialize to the storage form (serde_norway YAML — zynk's existing dep, no
    /// new deps). Round-trips back via `from_storage`.
    pub fn to_storage(&self) -> CliResult<String> {
        serde_norway::to_string(self)
            .map_err(|e| CliError::failure(format!("failed to serialize decision: {e}")))
    }

    /// The round-trip counterpart of `to_storage` (which the `decide` write path uses).
    /// Consumed by the v1 M2a R1 P1 `db import` rebuild
    /// (`db::rebuild_imported_operator_decision`), which reconstructs the typed
    /// `operator_decisions` row from a file-first decision audit's full-redaction payload.
    pub fn from_storage(text: &str) -> CliResult<Self> {
        serde_norway::from_str(text)
            .map_err(|e| CliError::failure(format!("failed to read decision: {e}")))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn gate_requires_valid_verdict() {
        let d = Decision::Gate {
            target_work_event_id: 3,
            verdict: "approve".into(),
            note: None,
        };
        assert!(d.validate().is_ok());
        let bad = Decision::Gate {
            target_work_event_id: 3,
            verdict: "maybe".into(),
            note: None,
        };
        assert!(
            bad.validate().is_err(),
            "verdict must be approve|request-changes"
        );
    }

    #[test]
    fn record_type_maps_each_variant() {
        assert_eq!(
            Decision::Gate {
                target_work_event_id: 1,
                verdict: "approve".into(),
                note: None
            }
            .record_type(),
            "gate-decision"
        );
        assert_eq!(
            Decision::Mode {
                mode_to: "decide".into()
            }
            .record_type(),
            "mode-switch"
        );
    }

    #[test]
    fn participant_overlay_is_non_transport_and_not_a_decision() {
        assert!(crate::decision::is_non_transport_proof_record_type(
            "participant-overlay"
        ));
        assert_eq!(DECISION_RECORD_TYPES.len(), 5);
        assert!(!DECISION_RECORD_TYPES.contains(&"participant-overlay"));
    }

    #[test]
    fn record_types_match_const() {
        // Every variant's record_type() is in DECISION_RECORD_TYPES, and the const
        // lists exactly the 5 decision types — keeps the const a faithful single
        // source of truth for the validate_audit_args §1a guard.
        let from_variants: std::collections::BTreeSet<&str> = [
            Decision::Gate {
                target_work_event_id: 1,
                verdict: "approve".into(),
                note: None,
            }
            .record_type(),
            Decision::Conflict {
                target_work_event_id: 1,
                resolution: "a".into(),
                note: None,
            }
            .record_type(),
            Decision::Mode {
                mode_to: "decide".into(),
            }
            .record_type(),
            Decision::Interrupt { reason: None }.record_type(),
            Decision::Redirect {
                target_agent: "codex".into(),
                reason: None,
            }
            .record_type(),
        ]
        .into_iter()
        .collect();
        let from_const: std::collections::BTreeSet<&str> =
            DECISION_RECORD_TYPES.iter().copied().collect();
        assert_eq!(
            from_variants, from_const,
            "DECISION_RECORD_TYPES must match Decision::record_type() exactly"
        );
    }

    #[test]
    fn mode_validates_against_adr020_canonical_set() {
        // ADR 020 (decisions/020-mode-switching.md:10-17): the workflow modes are
        // Brainstorm/Decide/Review/Validate/Debug. Each canonical mode validates; the
        // pre-fix non-protocol modes (plan/execute) must NOT.
        for mode in ["brainstorm", "decide", "review", "validate", "debug"] {
            assert!(
                Decision::Mode {
                    mode_to: mode.into()
                }
                .validate()
                .is_ok(),
                "ADR 020 canonical mode `{mode}` must validate"
            );
        }
        for mode in ["plan", "execute"] {
            assert!(
                Decision::Mode {
                    mode_to: mode.into()
                }
                .validate()
                .is_err(),
                "non-protocol mode `{mode}` must be rejected"
            );
        }
    }

    #[test]
    fn payload_round_trips() {
        let d = Decision::Conflict {
            target_work_event_id: 9,
            resolution: "option-b".into(),
            note: Some("n".into()),
        };
        let s = d.to_storage().unwrap();
        let back = Decision::from_storage(&s).unwrap();
        assert_eq!(d, back);
    }
}