devflow_core/ship_evidence.rs
1//! Read-only structural oracle: "did this phase actually ship?"
2//!
3//! Closes the false-green attestation class described in `23-06-PLAN.md`'s
4//! objective — an agent-authored attestation document (`VERIFICATION.md`,
5//! `SUMMARY.md`, …) previously had to be trusted or caught by a
6//! non-deterministic review prompt, because `devflow-core` never exposed its
7//! own append-only record of whether a phase actually reached a finalized
8//! Ship. [`collect`] exposes that record directly, and [`ShipEvidence::shipped`]
9//! is safe to declare as a `verify::external_verify_commands` probe (Layer 0,
10//! `agent_result.rs:704-711`): a failed declared probe outranks every
11//! agent-controlled signal.
12//!
13//! **This module is opt-in per phase, deliberately, and this module does not
14//! itself decide whether Layer 0 is active** — it only reports facts. A
15//! declared command must be approved via
16//! [`crate::verify::TRUST_EXTERNAL_VERIFY_ENV`] before it ever runs, on top
17//! of whatever project-level configuration gates declared-probe execution in
18//! the first place. This module must not be, and is not, the thing that
19//! flips that switch on: a default-on, unconditional `--require-shipped`
20//! probe would fail at every pre-Ship stage of every phase and block all
21//! work (T-23-64). Declaring this probe is a per-phase choice a PLAN author
22//! makes when a phase's own attestation claims a completed Ship.
23
24use crate::git::GitFlow;
25use crate::phase_id::PhaseId;
26use crate::stage::Stage;
27use crate::{events, workflow};
28use serde::Serialize;
29use std::path::Path;
30
31/// The name of the event marking a phase as ended after one stage, still
32/// carrying `workflow_finished` — kept as a named constant so its meaning
33/// doesn't have to be re-derived at every call site that needs to explain
34/// the ambiguity.
35const STOPPED_AT_REASON: &str = "stopped_at";
36
37/// The literal event name emitted at exactly one site —
38/// `pipeline_gate::finish_workflow_with_gate_timeout`, after the entire
39/// `hooks_after_ship` batch has succeeded — after which this module's
40/// `shipped` predicate is true.
41const WORKFLOW_SHIPPED_EVENT: &str = "workflow_shipped";
42
43/// The older, ambiguous event name. Emitted at TWO sites (see
44/// [`ShipEvidence::shipped`]'s doc comment): real Ship finalization, and
45/// `transition`'s `--until` clean-stop branch. Deliberately not the
46/// predicate.
47const WORKFLOW_FINISHED_EVENT: &str = "workflow_finished";
48
49/// DevFlow's own structural record of whether a phase has shipped.
50///
51/// Every field degrades to its safest value rather than erroring —
52/// [`collect`] returns a value, never a `Result` — because an oracle that
53/// can fail is an oracle a reviewer will learn to skip.
54#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
55pub struct ShipEvidence {
56 /// The phase this evidence was collected for.
57 pub phase: PhaseId,
58 /// The strict shipped predicate: whether the terminal-only
59 /// `workflow_shipped` event has been emitted for this phase.
60 ///
61 /// **This is the load-bearing field of the whole module — read this
62 /// comment before touching it.** An earlier revision of the plan that
63 /// produced this module defined the shipped predicate as "whether
64 /// `workflow_finished` has been emitted", asserted three separate times
65 /// that it was the only site emitting that event, and was proven wrong
66 /// by a cross-AI review before landing: `workflow_finished` is emitted at
67 /// TWO sites. The first is real Ship finalization
68 /// (`pipeline_gate::finish_workflow_with_gate_timeout`), guarded by the
69 /// entire `hooks_after_ship` batch succeeding. The second is
70 /// `transition`'s `devflow start --until <stage>` clean-stop branch
71 /// (`crates/devflow-cli/src/pipeline_gate.rs`, the
72 /// `state.stop_until == Some(from)` arm near the top of `transition`),
73 /// which emits `workflow_finished` with `{"reason": "stopped_at", …}`
74 /// and `return`s BEFORE any checkout hook, before `state.stage = to`,
75 /// before the `"transition"` event, and before `launch_stage` — nothing
76 /// resembling a Ship has run. Had `workflow_finished` stayed the
77 /// predicate, a phase halted after one stage would read as shipped: a
78 /// false green inside the very oracle built to eliminate false greens.
79 ///
80 /// The fix is structural, not a payload convention: a distinct,
81 /// terminal-only `workflow_shipped` event, emitted at exactly one site,
82 /// strictly after the `hooks_after_ship` batch's success loop breaks and
83 /// strictly before the (unchanged) `workflow_finished` emission there.
84 /// `shipped` reads ONLY this event — it must never fall back to
85 /// filtering `workflow_finished` on `reason != "stopped_at"`, because
86 /// that is a payload-discipline convention a future third emitter could
87 /// silently violate (the real finalization payload is literally `Null`
88 /// today, so "absence of a `reason` key" is exactly the fingerprint a
89 /// careless new emitter would also have).
90 ///
91 /// Git ancestry is also deliberately not the predicate: `merged_into_develop`
92 /// is shape-sensitive (a squash merge does not preserve the ancestry
93 /// `is_merged_into_develop` checks), and it goes false for every
94 /// successfully shipped phase once `BranchCleanup` — the hook that runs
95 /// immediately after `Merge` in the very same `hooks_after_ship` batch —
96 /// deletes the feature branch the ancestry check depends on. Git facts are
97 /// reported below as corroboration only and never gate `shipped`.
98 ///
99 /// Phases that finalized before this event existed have no
100 /// `workflow_shipped` line in their event log, so this reports `false`
101 /// for them. That fail-closed direction is deliberate: an oracle that
102 /// under-claims is safe, one that over-claims is the defect class this
103 /// module exists to remove.
104 pub shipped: bool,
105 /// Corroboration only: whether the older `workflow_finished` event has
106 /// ever been emitted for this phase. Never consulted by `shipped`.
107 pub workflow_finished_seen: bool,
108 /// The `reason` field from the last `workflow_finished` event, if any
109 /// event exists and it carried one. A value of `"stopped_at"` is what
110 /// distinguishes a `--until` halt from a real finalization — surfaced
111 /// here so the ambiguity is legible in the oracle's own output instead
112 /// of hidden inside this module's implementation.
113 pub finished_reason: Option<String>,
114 /// The phase's current stage, read from its persisted state file, or
115 /// `None` when no state file exists (state is cleared once a phase
116 /// finalizes — see `finish_workflow_with_gate_timeout`'s
117 /// `workflow::clear_state` call).
118 pub stage: Option<Stage>,
119 /// Whether a state file exists at all for this phase.
120 pub state_present: bool,
121 /// Whether the phase's `feature/phase-NN` branch currently exists.
122 pub feature_branch_exists: bool,
123 /// Whether that branch (if it exists) is an ancestor of `develop`.
124 /// Corroboration only — see `shipped`'s doc comment for why this is not
125 /// the predicate.
126 pub merged_into_develop: bool,
127 /// Whether the repository has at least one configured remote.
128 pub has_remote: bool,
129}
130
131/// Collect DevFlow's own structural record of whether `phase` has shipped.
132///
133/// Nothing in this module writes, commits, checks out, or emits — it is
134/// strictly read-only. Every field degrades to its safest value rather than
135/// failing: a root with no `.devflow` directory at all still returns a valid
136/// `ShipEvidence` with `shipped: false` and `state_present: false`, never a
137/// panic.
138pub fn collect(project_root: &Path, phase: PhaseId) -> ShipEvidence {
139 // The strict predicate, and nothing else — see `ShipEvidence::shipped`'s
140 // doc comment for why this must never consult `workflow_finished_seen`,
141 // `finished_reason`, or any git field.
142 let shipped = events::has_event_for_phase(project_root, phase, WORKFLOW_SHIPPED_EVENT);
143
144 let last_finished =
145 events::last_event_of_kind_for_phase(project_root, phase, WORKFLOW_FINISHED_EVENT);
146 let workflow_finished_seen = last_finished.is_some();
147 let finished_reason = last_finished
148 .as_ref()
149 .and_then(|event| event.get("reason"))
150 .and_then(|reason| reason.as_str())
151 .map(str::to_owned);
152
153 let (stage, state_present) = match workflow::load_state(project_root, phase) {
154 Ok(state) => (Some(state.stage), true),
155 Err(_) => (None, false),
156 };
157
158 let git = GitFlow::new(project_root);
159 let branch = format!(
160 "{}phase-{}",
161 crate::config::GitFlowConfig::default().feature_prefix,
162 phase.padded()
163 );
164 let feature_branch_exists = git.branch_exists(&branch);
165 let merged_into_develop = git.is_merged_into_develop(phase);
166 let has_remote = git.has_remote();
167
168 ShipEvidence {
169 phase,
170 shipped,
171 workflow_finished_seen,
172 finished_reason,
173 stage,
174 state_present,
175 feature_branch_exists,
176 merged_into_develop,
177 has_remote,
178 }
179}
180
181/// Whether `finished_reason` names the `--until` clean-stop branch, so
182/// callers (the CLI's `--require-shipped` failure message) can say "it
183/// finished but it did not ship" instead of a generic "not shipped" — the
184/// confusing case a reader hits first, per the plan's Task 1 acceptance
185/// criteria.
186pub fn is_stopped_at(evidence: &ShipEvidence) -> bool {
187 evidence.finished_reason.as_deref() == Some(STOPPED_AT_REASON)
188}
189
190#[cfg(test)]
191mod tests {
192 use super::*;
193 use crate::state::{AgentKind, State};
194
195 fn init_repo(root: &Path) {
196 let git = |args: &[&str]| {
197 let ok = crate::test_support::git_command(root)
198 .args(args)
199 .output()
200 .unwrap()
201 .status
202 .success();
203 assert!(ok, "git {args:?} failed");
204 };
205 git(&["init", "-q"]);
206 git(&["config", "user.email", "test@example.com"]);
207 git(&["config", "user.name", "Test"]);
208 git(&["config", "commit.gpgsign", "false"]);
209 git(&["config", "core.hooksPath", "/dev/null"]);
210 std::fs::write(root.join("README.md"), "init\n").unwrap();
211 git(&["add", "."]);
212 git(&["commit", "-q", "-m", "init"]);
213 git(&["branch", "-M", "main"]);
214 git(&["checkout", "-q", "-b", "develop"]);
215 }
216
217 /// The blocker's regression guard: a phase halted by `transition`'s
218 /// `--until` clean-stop branch emits `workflow_finished` with a
219 /// `"stopped_at"` reason and NOTHING else — `shipped` must read false.
220 #[test]
221 fn stopped_at_phase_reports_not_shipped_but_corroborates_finished() {
222 let dir = tempfile::tempdir().unwrap();
223 events::emit(
224 dir.path(),
225 PhaseId::new(42),
226 "workflow_finished",
227 serde_json::json!({"reason": "stopped_at", "stage": "plan"}),
228 );
229
230 let evidence = collect(dir.path(), PhaseId::new(42));
231 assert!(
232 !evidence.shipped,
233 "a phase that only stopped must not read as shipped"
234 );
235 assert!(evidence.workflow_finished_seen);
236 assert_eq!(evidence.finished_reason.as_deref(), Some("stopped_at"));
237 assert!(is_stopped_at(&evidence));
238 }
239
240 #[test]
241 fn shipped_event_is_true_only_for_the_phase_it_names() {
242 let dir = tempfile::tempdir().unwrap();
243 events::emit(
244 dir.path(),
245 PhaseId::new(7),
246 "workflow_shipped",
247 serde_json::json!({"stage": "ship"}),
248 );
249
250 assert!(collect(dir.path(), PhaseId::new(7)).shipped);
251 assert!(!collect(dir.path(), PhaseId::new(8)).shipped);
252 }
253
254 #[test]
255 fn shipped_predicate_consults_no_git_field() {
256 let dir = tempfile::tempdir().unwrap();
257 init_repo(dir.path());
258 crate::test_support::git_command(dir.path())
259 .args(["branch", "feature/phase-05", "develop"])
260 .status()
261 .unwrap();
262 // The branch exists and IS merged into develop (it was branched
263 // from develop's tip), and a remote is configured — every git field
264 // true — but no shipped event was ever emitted.
265 crate::test_support::git_command(dir.path())
266 .args([
267 "remote",
268 "add",
269 "origin",
270 "https://example.invalid/repo.git",
271 ])
272 .status()
273 .unwrap();
274
275 let evidence = collect(dir.path(), PhaseId::new(5));
276 assert!(evidence.feature_branch_exists);
277 assert!(evidence.merged_into_develop);
278 assert!(evidence.has_remote);
279 assert!(
280 !evidence.shipped,
281 "shipped must not be inferred from any git field"
282 );
283 }
284
285 #[test]
286 fn torn_final_line_does_not_hide_an_earlier_shipped_event() {
287 let dir = tempfile::tempdir().unwrap();
288 events::emit(
289 dir.path(),
290 PhaseId::new(9),
291 "workflow_shipped",
292 serde_json::json!({"stage": "ship"}),
293 );
294 let path = events::events_path(dir.path());
295 let mut contents = std::fs::read_to_string(&path).unwrap();
296 contents.push_str("{truncated\n");
297 std::fs::write(&path, contents).unwrap();
298
299 assert!(collect(dir.path(), PhaseId::new(9)).shipped);
300 }
301
302 #[test]
303 fn missing_devflow_dir_degrades_safely_without_panicking() {
304 let dir = tempfile::tempdir().unwrap();
305 let evidence = collect(dir.path(), PhaseId::new(1));
306 assert!(!evidence.shipped);
307 assert!(!evidence.state_present);
308 assert!(evidence.stage.is_none());
309 assert!(!evidence.workflow_finished_seen);
310 }
311
312 #[test]
313 fn collect_reports_stage_and_state_present_from_live_state() {
314 let dir = tempfile::tempdir().unwrap();
315 let state = State::new(
316 PhaseId::new(3),
317 AgentKind::Claude,
318 crate::mode::Mode::Auto,
319 dir.path().to_path_buf(),
320 );
321 workflow::save_state(&state).unwrap();
322
323 let evidence = collect(dir.path(), PhaseId::new(3));
324 assert!(evidence.state_present);
325 assert_eq!(evidence.stage, Some(Stage::Define));
326 assert!(!evidence.shipped);
327 }
328}