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