use crate::{CliError, CliResult};
use serde::{Deserialize, Serialize};
pub const CANONICAL_MODES: &[&str] = &["brainstorm", "decide", "review", "validate", "debug"];
pub const DECISION_RECORD_TYPES: &[&str] = &[
"gate-decision",
"conflict-resolve",
"mode-switch",
"interrupt",
"redirect",
];
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
}
#[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 {
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",
}
}
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,
}
}
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"),
}
}
pub fn to_storage(&self) -> CliResult<String> {
serde_norway::to_string(self)
.map_err(|e| CliError::failure(format!("failed to serialize decision: {e}")))
}
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() {
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() {
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);
}
}