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::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}