use std::{fs, path::Path};
use anyhow::{anyhow, bail, Result};
use serde::Deserialize;
use crate::tui::stage::Stage;
use super::engine::{is_planning, next_stage};
use super::state::{self, Approval, Channel, DecisionKind, EngineStatus};
#[derive(Clone, Debug, Default, Deserialize)]
pub struct Approvers {
#[serde(default)]
pub cli: Vec<String>,
#[serde(default)]
pub slack: Vec<String>,
}
impl Approvers {
fn list(&self, channel: Channel) -> &[String] {
match channel {
Channel::Cli => &self.cli,
Channel::Slack => &self.slack,
Channel::Automation => &[],
}
}
}
pub fn authorize(approvers: &Approvers, channel: Channel, author: &str) -> Result<()> {
let list = approvers.list(channel);
if list.is_empty() {
bail!("default-deny: nenhum aprovador configurado para o canal {channel:?}");
}
if !list.iter().any(|a| a == author) {
bail!("autor '{author}' não autorizado no canal {channel:?}");
}
Ok(())
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DecisionOutcome {
Advanced,
ReadyForExec,
Reopened,
}
#[allow(clippy::too_many_arguments)]
pub fn apply_decision(
root: &Path,
slug: &str,
gate: Stage,
decision: DecisionKind,
author: &str,
channel: Channel,
ts: &str,
reason: Option<String>,
approvers: &Approvers,
) -> Result<DecisionOutcome> {
authorize(approvers, channel, author)?;
apply_decision_recorded(root, slug, gate, decision, author, channel, ts, reason)
}
pub fn apply_automatic_acceptance(
root: &Path,
slug: &str,
gate: Stage,
ts: &str,
reason: Option<String>,
) -> Result<DecisionOutcome> {
apply_decision_recorded(
root,
slug,
gate,
DecisionKind::Approve,
"sdd-auto",
Channel::Automation,
ts,
reason,
)
}
#[allow(clippy::too_many_arguments)]
fn apply_decision_recorded(
root: &Path,
slug: &str,
gate: Stage,
decision: DecisionKind,
author: &str,
channel: Channel,
ts: &str,
reason: Option<String>,
) -> Result<DecisionOutcome> {
let mut st = state::load(root, slug)?.ok_or_else(|| anyhow!("demanda '{slug}' sem estado"))?;
if st.status != EngineStatus::AwaitingApproval {
bail!("demanda '{slug}' não está aguardando aprovação");
}
if st.cursor != gate.key() {
bail!(
"gate '{}' não corresponde ao cursor '{}'",
gate.key(),
st.cursor
);
}
st.approvals.push(Approval {
slug: slug.to_string(),
gate: gate.key().to_string(),
decision,
author: author.to_string(),
channel,
ts: ts.to_string(),
reason,
});
let outcome = match decision {
DecisionKind::Approve => {
let store = crate::worktree_store_dir(root, slug);
let filename = crate::stage_file(gate.key())
.ok_or_else(|| anyhow!("etapa de aprovação desconhecida: {}", gate.key()))?;
let artifact = store.join(filename);
let content = fs::read_to_string(&artifact).map_err(|error| {
anyhow!(
"lendo artefato para aprovação {}: {error}",
artifact.display()
)
})?;
let expected = crate::sha256_hex(content.as_bytes());
crate::persist_generated_stage_if_match(
root,
slug,
gate.key(),
&content,
"approved",
true,
false,
false,
Some(&expected),
)?;
match next_stage(gate) {
Some(next) if is_planning(next) => {
st.cursor = next.key().to_string();
st.status = EngineStatus::Orchestrating;
DecisionOutcome::Advanced
}
_ => {
#[cfg(feature = "html-gen")]
materialize_planning_html(root, slug)?;
st.status = EngineStatus::ReadyForExec;
DecisionOutcome::ReadyForExec
}
}
}
DecisionKind::Reject => {
st.status = EngineStatus::Orchestrating;
DecisionOutcome::Reopened
}
};
state::save(root, &st)?;
Ok(outcome)
}
pub fn guard_planning_html_current(root: &Path, slug: &str) -> Result<()> {
let store = crate::worktree_store_dir(root, slug);
guard_planning_html_store_current(&store)
}
#[cfg(feature = "html-gen")]
pub fn guard_planning_html_store_current(store: &Path) -> Result<()> {
use crate::domain::html::{PlanningHtmlCheck, PlanningHtmlCompiler, PlanningHtmlStaleReason};
match PlanningHtmlCompiler::check(store) {
Ok(PlanningHtmlCheck::Current) => Ok(()),
Ok(PlanningHtmlCheck::Missing { target }) => bail!(
"Execution bloqueada: {target} ausente em {}. \
Aprove/regenere o último gate de planejamento para materializar o HTML derivado atual.",
store.display()
),
Ok(PlanningHtmlCheck::Stale { reason }) => match reason {
PlanningHtmlStaleReason::SourceHashMismatch {
stage,
path,
expected,
actual,
} => bail!(
"Execution bloqueada: source drift em {stage} ({path}). \
traceability-map esperava sha256 {expected}, arquivo atual é {actual}. \
Salve novamente o artefato canônico ou aprove/regenere o planejamento."
),
PlanningHtmlStaleReason::FingerprintMismatch { expected, actual } => bail!(
"Execution bloqueada: 05-planning.html stale. \
Fingerprint registrado {expected}, fingerprint atual {actual}. \
Regenere 05-planning.html antes de executar."
),
PlanningHtmlStaleReason::HtmlHashMismatch { expected, actual } => bail!(
"Execution bloqueada: 05-planning.html adulterado ou incompatível. \
Hash registrado {expected}, arquivo atual {actual}. \
Regenere 05-planning.html a partir das fontes canônicas."
),
PlanningHtmlStaleReason::MetadataIncomplete { field } => bail!(
"Execution bloqueada: metadata incompleta em derived_artifacts.planning_html ({field}). \
Regenere 05-planning.html antes de executar."
),
},
Err(error) => bail!(
"Execution bloqueada: 05-planning.html incompatível com o artifact store atual em {}: {error:#}. \
Regenere o HTML derivado antes de executar.",
store.display()
),
}
}
#[cfg(not(feature = "html-gen"))]
pub fn guard_planning_html_store_current(_store: &Path) -> Result<()> {
bail!(
"Execution bloqueada: validação de 05-planning.html requer build com feature html-gen. \
Recompile com: cargo build ou cargo build --features html-gen"
)
}
#[cfg(feature = "html-gen")]
pub(crate) fn materialize_planning_html(root: &Path, slug: &str) -> Result<()> {
let store = crate::worktree_store_dir(root, slug);
let output = crate::domain::html::PlanningHtmlCompiler::compile(&store)?;
let target = store.join(crate::domain::html::PLANNING_HTML_FILENAME);
crate::artifact_store::write_atomic_unique(&target, output.html.as_bytes())?;
let service = crate::artifact_store::ArtifactStoreService::new(root, &store, slug);
service.record_planning_html(crate::artifact_store::PlanningHtmlRecord {
file: crate::domain::html::PLANNING_HTML_FILENAME.to_string(),
generator_version: output.fingerprint.generator_version,
fingerprint: output.fingerprint.sha256.clone(),
sha256: output.sha256.clone(),
size_bytes: output.html.len() as u64,
generated_at: output.generated_at.clone(),
sources: output
.sources
.into_iter()
.map(
|source| crate::artifact_store::DerivedArtifactSourceRecord {
stage: source.stage,
path: source.path,
state: source.state,
revision: source.revision,
sha256: source.sha256,
},
)
.collect(),
})?;
match crate::domain::html::PlanningHtmlCompiler::check(&store)? {
crate::domain::html::PlanningHtmlCheck::Current => Ok(()),
status => {
bail!("05-planning.html derivado não está current após materialização: {status:?}")
}
}
}
#[cfg(feature = "html-gen")]
pub(crate) fn materialize_delivery_html(root: &Path, slug: &str) -> Result<()> {
let store = crate::worktree_store_dir(root, slug);
let output = crate::domain::html::DeliveryHtmlCompiler::compile(&store)?;
let target = store.join(crate::domain::html::DELIVERY_HTML_FILENAME);
crate::artifact_store::write_atomic_unique(&target, output.html.as_bytes())?;
let service = crate::artifact_store::ArtifactStoreService::new(root, &store, slug);
service.record_delivery_html(crate::artifact_store::PlanningHtmlRecord {
file: crate::domain::html::DELIVERY_HTML_FILENAME.to_string(),
generator_version: output.fingerprint.generator_version,
fingerprint: output.fingerprint.sha256,
sha256: output.sha256,
size_bytes: output.html.len() as u64,
generated_at: output.generated_at,
sources: output
.sources
.into_iter()
.map(
|source| crate::artifact_store::DerivedArtifactSourceRecord {
stage: source.stage,
path: source.path,
state: source.state,
revision: source.revision,
sha256: source.sha256,
},
)
.collect(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::orchestrator::demand::{Demand, DemandSource, DemandType};
use crate::domain::orchestrator::engine::{tick, FakeRunner};
use crate::domain::orchestrator::{queue, state};
fn approvers() -> Approvers {
Approvers {
cli: vec!["alan".to_string()],
slack: vec![],
}
}
fn enqueue(root: &Path) -> String {
let d = Demand::new(
"DEM-1",
DemandType::Story,
"Esteira Autônoma",
"desc",
DemandSource::Cli,
None,
"2026-06-08T00:00:00Z",
)
.unwrap();
queue::enqueue(root, &d).unwrap();
d.slug
}
fn drive_to_prd_gate(root: &Path) -> String {
let slug = enqueue(root);
tick(root, &FakeRunner, "tick", "t0").unwrap(); tick(root, &FakeRunner, "tick", "t1").unwrap(); let st = state::load(root, &slug).unwrap().unwrap();
assert_eq!(st.status, EngineStatus::AwaitingApproval);
slug
}
#[test]
fn authorize_is_default_deny() {
let a = approvers();
assert!(authorize(&a, Channel::Cli, "alan").is_ok());
assert!(authorize(&a, Channel::Cli, "mallory").is_err());
assert!(authorize(&a, Channel::Slack, "alan").is_err());
assert!(authorize(&Approvers::default(), Channel::Cli, "alan").is_err());
}
#[test]
fn engine_never_self_approves() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let slug = drive_to_prd_gate(root);
let st = state::load(root, &slug).unwrap().unwrap();
assert!(
st.approvals.is_empty(),
"o motor não pode criar registros de aprovação"
);
}
#[test]
fn approve_prd_advances_to_techspec() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let slug = drive_to_prd_gate(root);
let outcome = apply_decision(
root,
&slug,
Stage::Prd,
DecisionKind::Approve,
"alan",
Channel::Cli,
"t2",
None,
&approvers(),
)
.unwrap();
assert_eq!(outcome, DecisionOutcome::Advanced);
let st = state::load(root, &slug).unwrap().unwrap();
assert_eq!(st.status, EngineStatus::Orchestrating);
assert_eq!(st.cursor, "techspec");
assert_eq!(st.approvals.len(), 1);
let map =
std::fs::read_to_string(root.join("docs").join(&slug).join("traceability-map.yaml"))
.unwrap();
let prd_block = map.split("prd:").nth(1).unwrap();
assert!(prd_block.contains("state: approved"));
}
#[test]
fn unauthorized_author_is_rejected() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let slug = drive_to_prd_gate(root);
let err = apply_decision(
root,
&slug,
Stage::Prd,
DecisionKind::Approve,
"mallory",
Channel::Cli,
"t2",
None,
&approvers(),
)
.unwrap_err();
assert!(err.to_string().contains("não autorizado"));
let st = state::load(root, &slug).unwrap().unwrap();
assert!(st.approvals.is_empty());
assert_eq!(st.status, EngineStatus::AwaitingApproval);
}
#[test]
fn approving_when_not_awaiting_fails() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let slug = enqueue(root);
tick(root, &FakeRunner, "tick", "t0").unwrap(); let err = apply_decision(
root,
&slug,
Stage::Prd,
DecisionKind::Approve,
"alan",
Channel::Cli,
"t1",
None,
&approvers(),
)
.unwrap_err();
assert!(err.to_string().contains("aguardando aprovação"));
}
#[test]
fn reject_reopens_gate_for_regeneration() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let slug = drive_to_prd_gate(root);
let outcome = apply_decision(
root,
&slug,
Stage::Prd,
DecisionKind::Reject,
"alan",
Channel::Cli,
"t2",
Some("escopo amplo demais".to_string()),
&approvers(),
)
.unwrap();
assert_eq!(outcome, DecisionOutcome::Reopened);
let st = state::load(root, &slug).unwrap().unwrap();
assert_eq!(st.status, EngineStatus::Orchestrating);
assert_eq!(st.cursor, "prd", "cursor permanece para regenerar a etapa");
assert_eq!(
st.approvals.last().unwrap().reason.as_deref(),
Some("escopo amplo demais")
);
let report = tick(root, &FakeRunner, "tick", "t3").unwrap();
assert_eq!(report.produced, vec![(slug.clone(), "prd".to_string())]);
}
#[test]
fn full_planning_loop_reaches_ready_for_exec() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let slug = drive_to_prd_gate(root);
let a = approvers();
assert_eq!(
apply_decision(
root,
&slug,
Stage::Prd,
DecisionKind::Approve,
"alan",
Channel::Cli,
"t2",
None,
&a
)
.unwrap(),
DecisionOutcome::Advanced
);
tick(root, &FakeRunner, "tick", "t3").unwrap(); assert_eq!(
apply_decision(
root,
&slug,
Stage::Techspec,
DecisionKind::Approve,
"alan",
Channel::Cli,
"t4",
None,
&a
)
.unwrap(),
DecisionOutcome::Advanced
);
tick(root, &FakeRunner, "tick", "t5").unwrap(); let st = state::load(root, &slug).unwrap().unwrap();
assert_eq!(st.cursor, "refinement");
assert_eq!(st.status, EngineStatus::Orchestrating);
tick(root, &FakeRunner, "tick", "t6").unwrap(); assert_eq!(
apply_decision(
root,
&slug,
Stage::Refinement,
DecisionKind::Approve,
"alan",
Channel::Cli,
"t7",
None,
&a
)
.unwrap(),
DecisionOutcome::ReadyForExec
);
let st = state::load(root, &slug).unwrap().unwrap();
assert_eq!(st.status, EngineStatus::ReadyForExec);
assert_eq!(st.approvals.len(), 3);
let dest = root.join("docs").join(&slug);
for f in [
"01-idea.md",
"02-prd.md",
"03-techspec.md",
"04-tasks.md",
"05-refinement.md",
] {
assert!(dest.join(f).is_file(), "faltou {f}");
}
#[cfg(feature = "html-gen")]
{
assert!(
dest.join(crate::domain::html::PLANNING_HTML_FILENAME)
.is_file(),
"faltou 05-planning.html derivado"
);
let map = fs::read_to_string(dest.join("traceability-map.yaml")).unwrap();
let document: serde_yaml::Value = serde_yaml::from_str(&map).unwrap();
assert_eq!(
document["derived_artifacts"]["planning_html"]["file"].as_str(),
Some(crate::domain::html::PLANNING_HTML_FILENAME)
);
assert_eq!(
document["derived_artifacts"]["planning_html"]["canonical"].as_bool(),
Some(false)
);
}
}
#[cfg(feature = "html-gen")]
#[test]
fn planning_guard_reports_source_drift() {
let dir = tempfile::tempdir().unwrap();
let store = current_planning_store(dir.path());
fs::write(
store.join("04-tasks.md"),
"# Tasks\n\nAlterado depois do HTML.",
)
.unwrap();
let error = guard_planning_html_store_current(&store)
.unwrap_err()
.to_string();
assert!(error.contains("source drift"), "{error}");
assert!(error.contains("tasks"), "{error}");
}
#[cfg(feature = "html-gen")]
#[test]
fn planning_guard_reports_tampered_html() {
let dir = tempfile::tempdir().unwrap();
let store = current_planning_store(dir.path());
fs::write(
store.join(crate::domain::html::PLANNING_HTML_FILENAME),
"tampered",
)
.unwrap();
let error = guard_planning_html_store_current(&store)
.unwrap_err()
.to_string();
assert!(error.contains("adulterado"), "{error}");
assert!(error.contains("05-planning.html"), "{error}");
}
#[cfg(feature = "html-gen")]
#[test]
fn planning_guard_reports_incompatible_store() {
let dir = tempfile::tempdir().unwrap();
let store = dir.path().join("docs/feat");
fs::create_dir_all(&store).unwrap();
fs::write(
store.join(crate::domain::html::PLANNING_HTML_FILENAME),
"orphan",
)
.unwrap();
let error = guard_planning_html_store_current(&store)
.unwrap_err()
.to_string();
assert!(error.contains("incompatível"), "{error}");
assert!(error.contains("artifact store"), "{error}");
}
#[cfg(feature = "html-gen")]
fn current_planning_store(root: &Path) -> std::path::PathBuf {
let store = root.join("docs/feat");
fs::create_dir_all(&store).unwrap();
write_planning_stage(&store, "02-prd.md", "# PRD\n\nConteúdo PRD.");
write_planning_stage(
&store,
"03-techspec.md",
"# Tech Spec\n\nConteúdo Tech Spec.",
);
write_planning_stage(&store, "04-tasks.md", "# Tasks\n\nConteúdo Tasks.");
write_planning_stage(
&store,
"05-refinement.md",
"# Refinement\n\nConteúdo Refinement.",
);
let map = format!(
"orchestration:\n name: Feat\nartifacts:\n prd:\n file: 02-prd.md\n state: approved\n revision: 1\n sha256: \"{}\"\n techspec:\n file: 03-techspec.md\n state: approved\n revision: 1\n sha256: \"{}\"\n tasks:\n file: 04-tasks.md\n state: approved\n revision: 1\n sha256: \"{}\"\n refinement:\n file: 05-refinement.md\n state: approved\n revision: 1\n sha256: \"{}\"\n",
crate::sha256_hex(&fs::read(store.join("02-prd.md")).unwrap()),
crate::sha256_hex(&fs::read(store.join("03-techspec.md")).unwrap()),
crate::sha256_hex(&fs::read(store.join("04-tasks.md")).unwrap()),
crate::sha256_hex(&fs::read(store.join("05-refinement.md")).unwrap())
);
fs::write(store.join("traceability-map.yaml"), map).unwrap();
let output = crate::domain::html::PlanningHtmlCompiler::compile(&store).unwrap();
fs::write(
store.join(crate::domain::html::PLANNING_HTML_FILENAME),
&output.html,
)
.unwrap();
crate::artifact_store::ArtifactStoreService::new(root, &store, "feat")
.record_planning_html(crate::artifact_store::PlanningHtmlRecord {
file: crate::domain::html::PLANNING_HTML_FILENAME.to_string(),
generator_version: output.fingerprint.generator_version,
fingerprint: output.fingerprint.sha256,
sha256: output.sha256,
size_bytes: output.html.len() as u64,
generated_at: output.generated_at,
sources: output
.sources
.into_iter()
.map(
|source| crate::artifact_store::DerivedArtifactSourceRecord {
stage: source.stage,
path: source.path,
state: source.state,
revision: source.revision,
sha256: source.sha256,
},
)
.collect(),
})
.unwrap();
store
}
#[cfg(feature = "html-gen")]
fn write_planning_stage(store: &Path, filename: &str, content: &str) {
fs::write(store.join(filename), content).unwrap();
}
}