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}