Skip to main content

cpm_planner/
plan.rs

1//! SPEC §33 PA1 — `Planner` data model.
2//!
3//! This module defines the *types* the [`Planner`] trait
4//! (see [`crate::ports::Planner`]) carries across the IP boundary. The trait
5//! itself lives in `ports.rs` next to the other runtime ports; everything an
6//! implementer needs to construct, mutate, or report on a plan is here.
7//!
8//! Two implementations satisfy the contract:
9//!
10//! - Open source: `mcp-flowgate-plan` ships a textbook critical-path-method
11//!   planner. Lives in this workspace, built in PA3.
12//! - Closed source: a richer FrontRails implementation lives in a separate
13//!   workspace and links the published `mcp-flowgate-core` crate.
14//!
15//! The runtime always holds an `Arc<dyn Planner>`; operators register whichever
16//! implementation suits their deployment.
17//!
18//! # Wire format
19//!
20//! Every data type derives `Serialize` + `Deserialize` because the MCP server
21//! layer (PA4) serialises plans, cohorts, and status snapshots into JSON-RPC
22//! responses. The unit test at the bottom of this file exercises a one-
23//! deliverable round-trip as a smoke test for the wire shape.
24//!
25//! # Locking semantics summary
26//!
27//! - A `PlanGraph` is submitted once; the implementation hashes
28//!   `(graph, caller)` and returns an existing [`PlanId`] on resubmit. Calls
29//!   are idempotent.
30//! - [`crate::ports::Planner::acquire_cohort`] returns a [`Cohort`]: a batch
31//!   of deliverables whose prerequisites are all [`DeliverableStatus::Complete`]
32//!   and whose owned-file sets are mutually disjoint *and* disjoint from every
33//!   currently held lock. The batch is locked atomically (PA3 guarantees this).
34//! - [`crate::ports::Planner::mark_status`] with `Complete` or `Failed`
35//!   releases the lock. A caller-id mismatch on the held lock yields
36//!   [`PlannerError::LockNotHeld`].
37//! - [`crate::ports::Planner::heartbeat`] refreshes the TTL; the TTL itself is
38//!   an implementation parameter (PA3 sets the open-source default at 5 min).
39//! - [`crate::ports::Planner::force_release`] is the operator escape hatch.
40//!   Implementations MUST emit an audit event carrying the supplied `reason`.
41
42use std::path::PathBuf;
43
44use chrono::{DateTime, Utc};
45use serde::{Deserialize, Serialize};
46use thiserror::Error;
47
48/// Opaque plan identifier returned by [`crate::ports::Planner::submit_plan`].
49///
50/// The string form is implementation-defined (UUID, deterministic content
51/// hash, ULID, etc.). Callers treat the value as opaque and round-trip it
52/// without inspecting the contents.
53#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
54pub struct PlanId(pub String);
55
56impl PlanId {
57    /// Borrow the underlying string for logging or hashing.
58    pub fn as_str(&self) -> &str {
59        &self.0
60    }
61}
62
63impl std::fmt::Display for PlanId {
64    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65        f.write_str(&self.0)
66    }
67}
68
69/// Opaque per-orchestrator identity. The Planner uses this to verify that
70/// the caller releasing a lock is the same caller who acquired it.
71///
72/// The string form is implementation-defined; it must be stable for the
73/// lifetime of a single orchestrator session.
74#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
75pub struct CallerId(pub String);
76
77impl CallerId {
78    pub fn as_str(&self) -> &str {
79        &self.0
80    }
81}
82
83impl std::fmt::Display for CallerId {
84    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85        f.write_str(&self.0)
86    }
87}
88
89/// Full plan submitted to [`crate::ports::Planner::submit_plan`].
90///
91/// A plan is a DAG of [`Deliverable`]s connected by the
92/// `prerequisites` field. The Planner is responsible for detecting cycles,
93/// missing prerequisite references, and duplicate ids; on any structural
94/// problem it returns [`PlannerError::InvalidGraph`] with a precise
95/// `reason`.
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct PlanGraph {
98    /// Every deliverable in the plan. Order is irrelevant; the Planner
99    /// derives execution order from the `prerequisites` edges.
100    pub deliverables: Vec<Deliverable>,
101
102    /// Optional global guardrail: maximum number of deliverables that may
103    /// be dispatched in one chained sequence before the orchestrator must
104    /// pause for explicit re-prompt. `None` means no limit. Carried at the
105    /// graph level rather than per-deliverable because it reflects an
106    /// operator policy, not a property of any single task.
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub max_chained_dispatch: Option<u32>,
109}
110
111/// A single unit of work scheduled by the Planner.
112///
113/// `owned_files` is the load-bearing field for concurrent dispatch: the
114/// Planner guarantees that two deliverables with overlapping `owned_files`
115/// will never be returned in the same [`Cohort`] and will never both hold
116/// active locks. This is the only mechanism the Planner uses to prevent
117/// write-write conflicts; implementations of [`crate::ports::Planner`]
118/// must therefore reject any plan that contains a deliverable whose
119/// `owned_files` are not specified up front.
120#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct Deliverable {
122    /// Unique identifier within the plan. The Planner rejects duplicate
123    /// ids at submit time with [`PlannerError::InvalidGraph`].
124    pub id: String,
125
126    /// Exact file paths the implementer is going to write while completing
127    /// this deliverable. Disjointness across this set is the lock-contention
128    /// invariant; see [`crate::ports::Planner::acquire_cohort`] semantics.
129    pub owned_files: Vec<PathBuf>,
130
131    /// Ids of other deliverables in the same plan that must reach
132    /// [`DeliverableStatus::Complete`] before this one becomes eligible
133    /// for acquisition.
134    pub prerequisites: Vec<String>,
135
136    /// Estimated wall-clock effort, used by critical-path math in
137    /// [`PlanStatus::critical_path`]. `None` means the implementation
138    /// should treat the duration as one unit when computing the longest
139    /// chain.
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub estimated_effort_hours: Option<f32>,
142
143    /// Free-form metadata. Conventionally carries model hints, human
144    /// descriptions, links to specs, etc. The Planner does not interpret
145    /// this field.
146    #[serde(default)]
147    pub metadata: serde_json::Value,
148}
149
150/// Lifecycle state of a single [`Deliverable`].
151///
152/// Transitions are driven by [`crate::ports::Planner::mark_status`] and by
153/// the Planner's own scheduling logic (e.g. `Pending` -> `Ready` when the
154/// last prerequisite completes).
155#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
156#[serde(tag = "status", rename_all = "snake_case")]
157pub enum DeliverableStatus {
158    /// At least one prerequisite is not yet complete.
159    Pending,
160    /// All prerequisites are complete; not yet acquired.
161    Ready,
162    /// A caller holds an active lock and is working on this deliverable.
163    InProgress,
164    /// The caller marked this deliverable complete. The lock has been
165    /// released.
166    Complete,
167    /// The caller marked this deliverable failed. The lock has been
168    /// released. The reason is preserved for audit and for human / planner
169    /// retry decisions.
170    Failed {
171        /// Human-readable failure reason supplied by the caller.
172        reason: String,
173    },
174}
175
176/// Snapshot of a held lock. The Planner records one [`LockInfo`] per
177/// acquired deliverable and surfaces them in [`Cohort::locks`] and
178/// [`PlanStatus::locks_held`].
179#[derive(Debug, Clone, Serialize, Deserialize)]
180pub struct LockInfo {
181    pub plan_id: PlanId,
182    pub deliverable_id: String,
183    pub caller_id: CallerId,
184    pub acquired_at: DateTime<Utc>,
185    /// TTL deadline. After this instant, the lock is treated as expired
186    /// and the Planner is free to release it on any subsequent operation.
187    pub expires_at: DateTime<Utc>,
188}
189
190/// One row in a [`Cohort`]: a deliverable held under a single lock.
191///
192/// Pairing the deliverable with its lock structurally makes the
193/// invariant "the i-th deliverable is held under the i-th lock"
194/// unrepresentable as broken at the type level. Pre-F5 the same
195/// invariant lived in a doc comment + a runtime test assertion; a
196/// future refactor that pushed to one parallel Vec but not the other
197/// would silently violate it.
198#[derive(Debug, Clone, Serialize, Deserialize)]
199pub struct CohortRow {
200    pub deliverable: Deliverable,
201    pub lock: LockInfo,
202}
203
204/// Result of a successful [`crate::ports::Planner::acquire_cohort`]
205/// call.
206///
207/// SPEC §33 audit fixup (F5 INTERFACE_GAP-001) — the previous shape
208/// was two parallel vectors (`deliverables: Vec<Deliverable>` +
209/// `locks: Vec<LockInfo>`) with the index-pairing invariant carried
210/// only in docs. F5 tightens to `rows: Vec<CohortRow>` so the
211/// invariant is type-enforced.
212///
213/// The wire shape is preserved: `#[serde(into = "FlatCohort", try_from =
214/// "FlatCohort")]` projects to/from the historical two-array JSON so
215/// MCP clients see no breaking change. Deserialization is fallible
216/// (CMP-032): mismatched `deliverables`/`locks` lengths are a corrupt
217/// payload and are rejected rather than silently truncated.
218#[derive(Debug, Clone, Serialize, Deserialize)]
219#[serde(into = "FlatCohort", try_from = "FlatCohort")]
220pub struct Cohort {
221    pub plan_id: PlanId,
222    pub rows: Vec<CohortRow>,
223}
224
225/// Error returned when a [`FlatCohort`] wire payload cannot be decoded into a
226/// [`Cohort`] — currently only the deliverables/locks length mismatch
227/// (CMP-032). Carries both lengths for triage and implements `Display` so it
228/// satisfies serde's `try_from` error bound.
229#[derive(Debug, Clone, PartialEq, Eq)]
230pub struct CohortDecodeError {
231    pub deliverables: usize,
232    pub locks: usize,
233}
234
235impl std::fmt::Display for CohortDecodeError {
236    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
237        write!(
238            f,
239            "COHORT_LENGTH_MISMATCH: deliverables ({}) and locks ({}) arrays \
240             must be the same length; each deliverable is held under exactly one lock",
241            self.deliverables, self.locks
242        )
243    }
244}
245
246impl std::error::Error for CohortDecodeError {}
247
248/// Wire-shape adapter for [`Cohort`] — keeps the historical
249/// `{plan_id, deliverables, locks}` JSON layout so the F5 in-Rust
250/// API tightening does NOT break MCP clients. NEVER referenced
251/// directly; only via the `From`/`Into` plumbing.
252#[derive(Debug, Clone, Serialize, Deserialize)]
253struct FlatCohort {
254    plan_id: PlanId,
255    deliverables: Vec<Deliverable>,
256    locks: Vec<LockInfo>,
257}
258
259impl From<Cohort> for FlatCohort {
260    fn from(cohort: Cohort) -> Self {
261        let mut deliverables = Vec::with_capacity(cohort.rows.len());
262        let mut locks = Vec::with_capacity(cohort.rows.len());
263        for row in cohort.rows {
264            deliverables.push(row.deliverable);
265            locks.push(row.lock);
266        }
267        FlatCohort {
268            plan_id: cohort.plan_id,
269            deliverables,
270            locks,
271        }
272    }
273}
274
275impl TryFrom<FlatCohort> for Cohort {
276    type Error = CohortDecodeError;
277
278    fn try_from(flat: FlatCohort) -> Result<Self, Self::Error> {
279        // CMP-032 — mismatched lengths mean an unpaired deliverable or lock.
280        // Previously this truncated to the shorter array, silently dropping
281        // lock-grant entries (or deliverables). That hides a real corruption,
282        // so we now reject the payload outright.
283        if flat.deliverables.len() != flat.locks.len() {
284            return Err(CohortDecodeError {
285                deliverables: flat.deliverables.len(),
286                locks: flat.locks.len(),
287            });
288        }
289        let rows = flat
290            .deliverables
291            .into_iter()
292            .zip(flat.locks)
293            .map(|(deliverable, lock)| CohortRow { deliverable, lock })
294            .collect();
295        Ok(Cohort {
296            plan_id: flat.plan_id,
297            rows,
298        })
299    }
300}
301
302/// Read-only snapshot of a plan's current state, returned by
303/// [`crate::ports::Planner::status`].
304#[derive(Debug, Clone, Serialize, Deserialize)]
305pub struct PlanStatus {
306    pub plan_id: PlanId,
307    /// Per-deliverable status. Order matches insertion order in the
308    /// originally submitted [`PlanGraph::deliverables`] so callers can
309    /// render a stable table.
310    pub deliverables: Vec<(String, DeliverableStatus)>,
311    /// Ids on the longest dependency chain, in execution order. Empty
312    /// when the plan has no deliverables.
313    pub critical_path: Vec<String>,
314    /// Sum of `estimated_effort_hours` along `critical_path`. Deliverables
315    /// without an estimate contribute zero.
316    pub critical_path_hours: f32,
317    /// Every lock currently active across the plan.
318    pub locks_held: Vec<LockInfo>,
319}
320
321/// Errors returned by [`crate::ports::Planner`] methods.
322///
323/// Every variant carries enough context to log without further lookup. The
324/// stable string token at the start of the `#[error(..)]` message doubles
325/// as the wire-level error code surfaced by PA4's MCP server, so the
326/// variant prefixes (`LOCK_HELD`, `LOCK_NOT_HELD`, etc.) MUST NOT change
327/// without bumping the MCP server schema.
328#[derive(Debug, Error)]
329pub enum PlannerError {
330    /// The requested deliverable is already locked by another caller. The
331    /// `holder` field surfaces the conflicting caller for human triage.
332    #[error("LOCK_HELD: deliverable {deliverable_id} in plan {plan_id} is locked by {holder}")]
333    LockHeld {
334        plan_id: String,
335        deliverable_id: String,
336        holder: String,
337    },
338
339    /// The caller invoked an operation that requires holding a lock
340    /// (`mark_status`, `heartbeat`) but the lock is held by someone else,
341    /// or no lock exists at all.
342    #[error("LOCK_NOT_HELD: caller {caller_id} does not hold lock on {deliverable_id}")]
343    LockNotHeld {
344        caller_id: String,
345        deliverable_id: String,
346    },
347
348    /// The lock the caller is referencing has passed its TTL. The Planner
349    /// is free to reclaim the deliverable for another caller.
350    #[error("LOCK_EXPIRED: lock on {deliverable_id} expired at {expired_at}")]
351    LockExpired {
352        deliverable_id: String,
353        expired_at: DateTime<Utc>,
354    },
355
356    /// `acquire_cohort` discovered that the candidate deliverable's
357    /// `owned_files` overlap with files held by an existing lock. Surfaced
358    /// as a distinct variant (rather than `LOCK_HELD`) because the
359    /// conflict is at the *file* level, not the deliverable level.
360    #[error(
361        "OVERLAP_DETECTED: deliverable {deliverable_id} owns files {files:?} that overlap with \
362         currently locked files"
363    )]
364    OverlapDetected {
365        deliverable_id: String,
366        files: Vec<PathBuf>,
367    },
368
369    /// `acquire_cohort` cannot include the candidate because at least one
370    /// of its prerequisites is not yet [`DeliverableStatus::Complete`].
371    /// This is not a hard error for the whole call (other cohort members
372    /// may still be returned); it surfaces when a caller explicitly
373    /// requests an ineligible deliverable.
374    #[error(
375        "MISSING_PREREQUISITE: deliverable {deliverable_id} requires {prereq} which is not \
376         Complete"
377    )]
378    MissingPrerequisite {
379        deliverable_id: String,
380        prereq: String,
381    },
382
383    /// The plan id supplied to a lookup or mutation does not correspond to
384    /// any submitted plan.
385    #[error("PLAN_NOT_FOUND: {plan_id}")]
386    PlanNotFound { plan_id: String },
387
388    /// The deliverable id supplied to a lookup or mutation does not
389    /// correspond to any deliverable in the named plan.
390    #[error("DELIVERABLE_NOT_FOUND: {deliverable_id} in plan {plan_id}")]
391    DeliverableNotFound {
392        plan_id: String,
393        deliverable_id: String,
394    },
395
396    /// The submitted graph fails a structural invariant: duplicate ids,
397    /// unknown prerequisite reference, cycle, empty `owned_files`, etc.
398    /// The `reason` is the precise failure message.
399    #[error("INVALID_GRAPH: {reason}")]
400    InvalidGraph { reason: String },
401
402    /// Catch-all for backend failures (DB unavailable, serialization
403    /// errors against the persistence layer, etc.). Wraps the underlying
404    /// `anyhow::Error` so the caller can introspect via `source()`.
405    #[error("BACKEND_ERROR: {0}")]
406    BackendError(#[source] anyhow::Error),
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412
413    /// Wire-shape smoke test: constructing a `PlanGraph` with one
414    /// deliverable and round-tripping it through `serde_json` must
415    /// preserve every field. The MCP server in PA4 depends on this
416    /// invariant.
417    #[test]
418    fn plan_graph_serde_roundtrip() -> Result<(), serde_json::Error> {
419        let graph = PlanGraph {
420            deliverables: vec![Deliverable {
421                id: "d1".to_string(),
422                owned_files: vec![PathBuf::from("src/foo.rs"), PathBuf::from("src/bar.rs")],
423                prerequisites: vec!["d0".to_string()],
424                estimated_effort_hours: Some(1.5),
425                metadata: serde_json::json!({"description": "smoke test"}),
426            }],
427            max_chained_dispatch: Some(8),
428        };
429
430        let json = serde_json::to_string(&graph)?;
431        let back: PlanGraph = serde_json::from_str(&json)?;
432
433        assert_eq!(back.deliverables.len(), 1);
434        let d = &back.deliverables[0];
435        assert_eq!(d.id, "d1");
436        assert_eq!(
437            d.owned_files,
438            vec![PathBuf::from("src/foo.rs"), PathBuf::from("src/bar.rs")]
439        );
440        assert_eq!(d.prerequisites, vec!["d0".to_string()]);
441        assert_eq!(d.estimated_effort_hours, Some(1.5));
442        assert_eq!(d.metadata, serde_json::json!({"description": "smoke test"}));
443        assert_eq!(back.max_chained_dispatch, Some(8));
444        Ok(())
445    }
446
447    /// `DeliverableStatus` uses an internally-tagged enum representation
448    /// so the wire form matches what PA4's MCP server publishes. Pin the
449    /// shape with an explicit JSON check so an accidental derive change
450    /// fails loudly.
451    #[test]
452    fn deliverable_status_failed_carries_reason() -> Result<(), serde_json::Error> {
453        let status = DeliverableStatus::Failed {
454            reason: "tests failed".to_string(),
455        };
456        let json = serde_json::to_value(&status)?;
457        assert_eq!(json["status"], "failed");
458        assert_eq!(json["reason"], "tests failed");
459        let back: DeliverableStatus = serde_json::from_value(json)?;
460        assert_eq!(back, status);
461        Ok(())
462    }
463
464    /// SPEC §33 audit fixup (F5 INTERFACE_GAP-001) — Cohort tightened
465    /// from parallel vectors to `Vec<CohortRow>`, but the JSON wire
466    /// shape MUST stay as `{plan_id, deliverables, locks}` so MCP
467    /// clients see no breaking change. Pin both directions of the
468    /// `serde(into/from)` adapter.
469    #[test]
470    fn cohort_wire_shape_preserves_two_array_layout() -> Result<(), serde_json::Error> {
471        let plan_id = PlanId("plan_x".to_string());
472        let now = chrono::Utc::now();
473        let cohort = Cohort {
474            plan_id: plan_id.clone(),
475            rows: vec![
476                CohortRow {
477                    deliverable: Deliverable {
478                        id: "d1".to_string(),
479                        owned_files: vec![PathBuf::from("a.rs")],
480                        prerequisites: vec![],
481                        estimated_effort_hours: Some(1.0),
482                        metadata: serde_json::Value::Null,
483                    },
484                    lock: LockInfo {
485                        plan_id: plan_id.clone(),
486                        deliverable_id: "d1".to_string(),
487                        caller_id: CallerId("c1".to_string()),
488                        acquired_at: now,
489                        expires_at: now + chrono::Duration::seconds(60),
490                    },
491                },
492                CohortRow {
493                    deliverable: Deliverable {
494                        id: "d2".to_string(),
495                        owned_files: vec![PathBuf::from("b.rs")],
496                        prerequisites: vec![],
497                        estimated_effort_hours: Some(2.0),
498                        metadata: serde_json::Value::Null,
499                    },
500                    lock: LockInfo {
501                        plan_id: plan_id.clone(),
502                        deliverable_id: "d2".to_string(),
503                        caller_id: CallerId("c1".to_string()),
504                        acquired_at: now,
505                        expires_at: now + chrono::Duration::seconds(60),
506                    },
507                },
508            ],
509        };
510        let json = serde_json::to_value(&cohort)?;
511        // Wire shape: top-level keys are plan_id + deliverables + locks
512        // (NOT `rows`). MCP clients depending on the historical shape
513        // continue to see it.
514        assert!(json.get("deliverables").is_some());
515        assert!(json.get("locks").is_some());
516        assert!(json.get("rows").is_none());
517        let deliverables = json["deliverables"].as_array().unwrap();
518        let locks = json["locks"].as_array().unwrap();
519        assert_eq!(deliverables.len(), 2);
520        assert_eq!(locks.len(), 2);
521        // Position-aligned pairing on the wire.
522        assert_eq!(deliverables[0]["id"], "d1");
523        assert_eq!(locks[0]["deliverable_id"], "d1");
524
525        // Round-trip back into the in-Rust row form.
526        let back: Cohort = serde_json::from_value(json)?;
527        assert_eq!(back.rows.len(), 2);
528        assert_eq!(back.rows[0].deliverable.id, "d1");
529        assert_eq!(back.rows[0].lock.deliverable_id, "d1");
530        assert_eq!(back.rows[1].deliverable.id, "d2");
531        assert_eq!(back.rows[1].lock.deliverable_id, "d2");
532        Ok(())
533    }
534
535    /// CMP-032 — a wire payload whose `deliverables` and `locks` arrays have
536    /// mismatched lengths is corrupt (an unpaired lock-grant or deliverable).
537    /// Deserialization MUST error rather than silently truncate to the shorter
538    /// array and drop the unpaired entry.
539    #[test]
540    fn cohort_rejects_mismatched_deliverables_and_locks_lengths() {
541        let now = chrono::Utc::now();
542        // Two deliverables but only one lock — the historical truncating impl
543        // would have dropped d2 silently.
544        let wire = serde_json::json!({
545            "plan_id": "plan_x",
546            "deliverables": [
547                {
548                    "id": "d1",
549                    "owned_files": ["a.rs"],
550                    "prerequisites": [],
551                    "estimated_effort_hours": 1.0,
552                    "metadata": null
553                },
554                {
555                    "id": "d2",
556                    "owned_files": ["b.rs"],
557                    "prerequisites": [],
558                    "estimated_effort_hours": 2.0,
559                    "metadata": null
560                }
561            ],
562            "locks": [
563                {
564                    "plan_id": "plan_x",
565                    "deliverable_id": "d1",
566                    "caller_id": "c1",
567                    "acquired_at": now,
568                    "expires_at": now + chrono::Duration::seconds(60)
569                }
570            ]
571        });
572
573        let err = serde_json::from_value::<Cohort>(wire).unwrap_err();
574        assert!(
575            err.to_string().contains("COHORT_LENGTH_MISMATCH"),
576            "expected COHORT_LENGTH_MISMATCH, got: {err}"
577        );
578    }
579}