Skip to main content

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}